# SteadyLink complete context
> SteadyLink gives a logical asset one stable delivery address while preserving each replacement as a revision. It combines object storage, malware scanning, image transforms, signed access, analytics, and edge delivery.
Canonical site: https://steadylink.io
## Product model
- A workspace contains buckets, settings, members, API keys, storage usage, and analytics.
- A bucket is a top-level organizational and policy boundary.
- Folders are paths inside a bucket.
- An asset is a logical file with a stable identity and delivery address.
- Replacing an asset creates a new revision without changing its stable address.
- Public assets can be fetched without a token.
- Private assets require authorized API access or a time-limited signed URL.
- Image transforms use delivery query parameters and never overwrite the stored original.
- Uploads move through transfer, finalization, malware scanning, and a terminal ready, blocked, or failed state.
- Dashboard previews and owner views must not count as public delivery traffic.
## Integration facts
- The management API base URL is https://api.steadylink.io.
- Trusted server integrations authenticate with X-API-Key. API keys are scoped to a workspace.
- A browser never needs a management key. Your server creates an upload session, returns only its short-lived uploadUrl, and the browser sends bytes directly to that URL.
- Complete an upload session with an Idempotency-Key. A client may retry a safe read, or a write that carries the same idempotency key.
- API failures include a request ID. Include that value when diagnosing a problem.
- The TypeScript SDK (@steadylink/sdk), CLI (@steadylink/cli), and Python client (steadylink) are published. The REST API and an S3-compatible API are live.
## Minimal API example
```bash
curl "https://api.steadylink.io/api/assets/?limit=20" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
## API conventions
- API base URL: https://api.steadylink.io
- Server integrations authenticate with the X-API-Key header.
- API keys are workspace-scoped and should receive only the scopes they need.
- Never embed workspace API keys in browsers, mobile binaries, public repositories, or public logs.
- Upload bytes are sent to a short-lived presigned storage URL, then the upload session is completed through the API.
- Completion requests support an Idempotency-Key so clients can retry safely.
- Upload status can be followed through the server-sent events status stream.
- Retry transient failures and rate limits with bounded exponential backoff.
- Include the returned request ID when reporting an API failure.
## Important pages
- [SteadyLink](https://steadylink.io/): Product overview for stable, versioned media delivery.
- [Platform](https://steadylink.io/products): Feature reference for storage, revisions, delivery, transforms, security, and analytics.
- [File converter](https://steadylink.io/convert): Temporary browser-based file conversion with optional permanent workspace storage.
- [Solutions](https://steadylink.io/solutions): Use cases for file sharing, photography, teams, developers, and creators: portfolios, press kits, course files, price lists, link-in-bio pages, and resumes.
- [Documentation](https://steadylink.io/docs): Overview of the SteadyLink docs: quickstart, guides, concepts, reference, and security.
- [Quickstart](https://steadylink.io/docs/quickstart): Upload a file, get its stable link, replace it, and confirm the link still works.
- [REST API reference](https://steadylink.io/docs/reference/api): Base URL, authentication, conventions, and every REST resource.
- [TypeScript SDK](https://steadylink.io/docs/reference/sdk): Every method in @steadylink/sdk.
- [CLI](https://steadylink.io/docs/reference/cli): Every command and flag in @steadylink/cli.
- [S3-compatible API](https://steadylink.io/docs/reference/s3): Use rclone, restic, Cyberduck, and the AWS CLI with SteadyLink buckets.
- [Privacy policy](https://steadylink.io/legal/privacy): SteadyLink privacy and data handling terms.
- [Terms of service](https://steadylink.io/legal/terms): Terms governing use of SteadyLink.
## Documentation index
### Get started
- [SteadyLink documentation](https://steadylink.io/docs): Host files behind links that never change. Upload once, replace the file whenever you need to, and every place that uses the link gets the new version.
- [Quickstart](https://steadylink.io/docs/quickstart): Upload a file, get its permanent link, replace the file, and watch the same link serve the new version. About five minutes.
### Guides
- [Guides](https://steadylink.io/docs/guides): Step-by-step walkthroughs for the jobs people use SteadyLink for, from replacing a live file to collecting assets from clients.
- [Upload and replace files](https://steadylink.io/docs/guides/upload-and-replace): 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.
- [Buckets and folders](https://steadylink.io/docs/guides/buckets-and-folders): Decide where files live, organize them with folders, rename and move them without breaking links, and control who can open them by default.
- [Image transformations](https://steadylink.io/docs/guides/image-transformations): Resize, crop, and convert images by adding query parameters to their stable link, with focal points for crops and cached variants that follow every replacement.
- [Collections and portals](https://steadylink.io/docs/guides/collections-and-portals): Put a curated set of files behind one link, choose who can open it, and keep it current as the files change.
- [Client proofing and watermarks](https://steadylink.io/docs/guides/client-proofing): Let clients heart their favorite photos in a collection and send you their picks, with downloads off or a watermark burned into every image.
- [Replacement requests](https://steadylink.io/docs/guides/replacement-requests): Send someone outside your workspace a link to upload a new version of one file, review it, then publish it now or at a time you choose.
- [Asset requests](https://steadylink.io/docs/guides/asset-requests): Collect a defined set of files from people outside your workspace through one expiring link, then review each file before it lands in a bucket.
- [Upload widget](https://steadylink.io/docs/guides/upload-widget): Accept files from visitors on your own website with an origin-restricted embed, review them before they go live, and react to completed uploads in JavaScript.
- [Browser uploads](https://steadylink.io/docs/guides/browser-uploads): Let people upload files from your web app straight to SteadyLink storage, with progress, while your API key stays on your server.
- [Website migration](https://steadylink.io/docs/guides/website-migration): Crawl a public website, see every image and file it references, import the ones you want into a bucket, and get a map from old URLs to stable SteadyLink links.
- [File conversions](https://steadylink.io/docs/guides/file-conversions): Convert images, documents, spreadsheets, audio, video, archives, and ebooks between formats, understand what each job costs, and save results into a bucket.
- [Notifications](https://steadylink.io/docs/guides/notifications): Choose which workspace events reach you, whether they arrive by email as they happen or once a day, and check that they were delivered.
- [Images in Next.js](https://steadylink.io/docs/guides/nextjs-images): Serve responsive images through next/image from SteadyLink links, so replacing a file updates every page without a redeploy.
- [Connect rclone in one command](https://steadylink.io/docs/guides/rclone): Use SteadyLink as S3-compatible storage for rclone. Install it, connect with an app key, mount buckets as a drive, or back up a folder on a schedule.
- [Use SteadyLink in Claude, ChatGPT, Cursor and more](https://steadylink.io/docs/guides/mcp): Add SteadyLink to Claude, ChatGPT, Codex, Cursor, VS Code and other AI tools with one URL, so you can upload files and get permanent links from a chat.
- [Set up SteadyLink with an AI assistant](https://steadylink.io/docs/guides/ai-assistants): Point ChatGPT, Claude, Cursor or any other AI assistant at the SteadyLink docs so it can walk you through setup or write the integration for you.
### Concepts
- [Concepts](https://steadylink.io/docs/concepts): The ideas behind SteadyLink. Read these once and the rest of the docs, the API, and the dashboard will make sense.
- [Stable links](https://steadylink.io/docs/concepts/stable-links): How one link keeps pointing at the right bytes while you replace a file, how to pin a specific revision, and what caches do in between.
- [Revisions](https://steadylink.io/docs/concepts/revisions): What SteadyLink keeps for every version of a file, how to label and restore revisions, and exactly what deleting one removes.
- [Upload lifecycle](https://steadylink.io/docs/concepts/uploads): The states an upload passes through, from upload session to scanned revision, and every check that can stop a file along the way.
- [Workspaces and roles](https://steadylink.io/docs/concepts/workspaces-and-roles): How workspaces separate files, people, and billing, what each member role can do, and how API keys and S3 app keys fit alongside signed-in users.
- [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits): What counts toward storage, delivery, and API usage, what happens at 50, 80, and 100 percent, and the limits on each plan.
### Reference
- [Reference](https://steadylink.io/docs/reference): Complete, exact reference for the SteadyLink REST API, client libraries, command-line tools, S3-compatible API, webhooks, and errors.
- [REST API: REST API](https://steadylink.io/docs/reference/api): Base URL, authentication headers, request and response conventions, pagination, idempotency, request IDs, and an index of every REST API resource.
- [REST API: Authentication](https://steadylink.io/docs/reference/api/authentication): Create, scope, expire, and revoke workspace API keys, and understand the plan limits and rate-limit headers that apply to every key.
- [REST API: Buckets](https://steadylink.io/docs/reference/api/buckets): List, create, read, configure, and delete buckets, the top-level containers that hold a workspace's folders and files.
- [REST API: Files and folders](https://steadylink.io/docs/reference/api/files): List, search, and inspect files, create and move folders, rename files, change visibility, replace a file's bytes without changing its link, and delete files.
- [REST API: Uploads](https://steadylink.io/docs/reference/api/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.
- [REST API: Revisions](https://steadylink.io/docs/reference/api/revisions): List the revisions behind a stable link, label them, roll back by promoting an earlier revision, and delete revisions you no longer need.
- [REST API: Delivery and signed links](https://steadylink.io/docs/reference/api/delivery): Serve files from their stable link, resize and convert images with query parameters, understand caching after replacements, and share private files with revocable signed links.
- [REST API: Analytics API](https://steadylink.io/docs/reference/api/analytics): Read delivery totals, zero-filled time series, top files, traffic sources, workspace activity, and storage ownership for one workspace.
- [REST API: Workspaces API](https://steadylink.io/docs/reference/api/workspaces): Read workspace identity and API entitlement, change workspace settings, manage members and invitations, and issue or revoke API keys.
- [REST API: Conversions API](https://steadylink.io/docs/reference/api/conversions): Convert images, documents, spreadsheets, audio, video, archives, and ebooks, then download the result or save it to a bucket.
- [REST API: Requests and migration API](https://steadylink.io/docs/reference/api/requests): Collect files from people outside your workspace, schedule revisions, import a website's media, and manage personal notification preferences.
- [REST API: Widgets and collections API](https://steadylink.io/docs/reference/api/sharing): Manage origin-restricted upload widgets and their submissions, and publish ordered collections as branded file portals.
- [REST API: Platform controls API](https://steadylink.io/docs/reference/api/platform): Configure regional delivery, image focal points, bulk migrations, delivery domains, encryption of originals, and single sign-on.
- [TypeScript SDK](https://steadylink.io/docs/reference/sdk): Every export in @steadylink/sdk 0.2.0, the official TypeScript client for uploads, replacements, links, revisions, collections, and workspace controls.
- [Python SDK](https://steadylink.io/docs/reference/python-sdk): Every public method in the steadylink Python package 0.1.0, a synchronous client with no runtime dependencies for uploads, replacements, and workspace controls.
- [CLI](https://steadylink.io/docs/reference/cli): Every command, flag, environment variable, exit code, and JSON output shape in @steadylink/cli 0.2.0.
- [GitHub Action](https://steadylink.io/docs/reference/github-action): Inputs, outputs, job summary, and failure behavior of SteadyLink-io/upload-action, which uploads or replaces files from a GitHub workflow.
- [S3-compatible API](https://steadylink.io/docs/reference/s3): Connect rclone, restic, Cyberduck, the AWS CLI, boto3, and NAS backup apps to SteadyLink buckets with app keys and AWS Signature Version 4.
- [Webhooks](https://steadylink.io/docs/reference/webhooks): Register webhook endpoints, receive signed events when revisions publish, scans finish, files or photo picks come in, migrations complete, or delivery crosses a threshold, and verify each request.
- [Errors](https://steadylink.io/docs/reference/errors): The SteadyLink error body, every status code and error code, rate limits and their headers, request IDs, and when it is safe to retry.
### Security
- [Security](https://steadylink.io/docs/security): How SteadyLink checks every upload, controls who can open a file, protects stored bytes, and records what happens in your workspace.
- [Two-step verification](https://steadylink.io/docs/security/two-factor): Require a code from an authenticator app when you sign in, keep recovery codes for a lost phone, and know what to do when something goes wrong.
- [Passkeys](https://steadylink.io/docs/security/passkeys): Sign in to SteadyLink with Face ID, Touch ID, Windows Hello, your phone or a security key instead of a password, and manage the passkeys saved to your account.
- [Private links](https://steadylink.io/docs/security/private-links): Decide who can open each file, share private files with expiring signed links, and revoke access before a link expires.
- [API key safety](https://steadylink.io/docs/security/api-keys): Give each integration the least access it needs, expire and rotate keys without downtime, and revoke them the moment something leaks.
- [Single sign-on](https://steadylink.io/docs/security/single-sign-on): Connect your OpenID Connect identity provider, limit it to your email domains, test it, and then require it for everyone in the workspace.
### Changelog
- [Changelog](https://steadylink.io/docs/changelog): New features, changes, and fixes in SteadyLink, newest first.
## Documentation
The full text of every documentation page, in the order above.
### SteadyLink documentation
Source: https://steadylink.io/docs
SteadyLink gives every file one permanent address. When you publish a new version, the address stays the same and starts serving the new bytes. Websites, emails, QR codes, apps, and printed material keep working without anyone editing a link.
#### Start here
- [Quickstart](https://steadylink.io/docs/quickstart): Upload a file, copy its link, replace the file, and watch the link keep working. About five minutes.
- [Upload and replace files](https://steadylink.io/docs/guides/upload-and-replace): The everyday workflow in the dashboard: batch uploads, scanning, and replacements.
- [How stable links work](https://steadylink.io/docs/concepts/stable-links): Current and pinned revisions, caching, and what changes when you replace a file.
- [REST API](https://steadylink.io/docs/reference/api): Every endpoint, with request and response examples in curl, JavaScript, and Python.
#### Build with SteadyLink
Pick the tool that fits where your code runs. They all use the same API and the same scoped workspace keys.
- [TypeScript SDK](https://steadylink.io/docs/reference/sdk): `@steadylink/sdk` for Node.js, edge runtimes, and browsers.
- [Python SDK](https://steadylink.io/docs/reference/python-sdk): `steadylink` on PyPI. Synchronous, no runtime dependencies.
- [CLI](https://steadylink.io/docs/reference/cli): Upload, replace, and print links from a terminal or CI job.
- [GitHub Action](https://steadylink.io/docs/reference/github-action): Publish release files from a workflow and keep their download links.
- [S3-compatible API](https://steadylink.io/docs/reference/s3): Use rclone, restic, Cyberduck, or the AWS CLI with your buckets.
- [Webhooks](https://steadylink.io/docs/reference/webhooks): Get signed events when revisions publish or scans finish.
#### The model in one minute
- A **workspace** holds your buckets, members, API keys, usage, and settings.
- A **bucket** is a top-level container with its own defaults. **Folders** are paths inside it.
- A **file** (also called an asset) has one stable identity and one stable link: `https://cdn.steadylink.io/a/{asset_id}`.
- Each upload to that file creates a new **revision**. The link serves whichever clean revision is current, and you can roll back at any time.
- Every upload is **scanned for malware**. A file that fails the scan is blocked from delivery.
- Files are **public** or **private**. Private files are shared with expiring, revocable **signed links**.
Read [Concepts](https://steadylink.io/docs/concepts) for the details behind each of these.
#### Get help
If something in these docs is wrong or missing, email [support@steadylink.io](mailto:support@steadylink.io) with the page URL. For API failures, include the `X-Request-Id` from the response so we can find the request in our logs. Current platform health is on the [status page](https://steadylink.io/status).
### Quickstart
Source: https://steadylink.io/docs/quickstart
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?](#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](https://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:
```bash
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](https://steadylink.io/docs/guides/browser-uploads).
**Install a client (optional)**
curl needs nothing else. The JSON parsing in the curl examples uses [`jq`](https://jqlang.org).
```bash title="JavaScript"
npm install @steadylink/sdk
```
```bash title="Python"
pip install steadylink
```
```bash title="CLI"
npm install -g @steadylink/cli
steadylink login --api-key "$STEADYLINK_API_KEY"
```
The CLI also reads `STEADYLINK_API_KEY` directly, so `login` is 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**).
```bash title="curl"
curl https://api.steadylink.io/api/assets/ \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
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}`));
```
```python title="Python"
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"])
```
```bash title="CLI"
steadylink ls
```
If 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.
```bash title="curl"
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"}'
```
```ts title="JavaScript"
const { id: bucketId } = await steadylink.createBucket("Marketing", "marketing");
```
```python title="Python"
bucket_id = client.request("POST", "/api/assets/", {"name": "Marketing", "slug": "marketing"})["id"]
```
```json title="Response" status="201 Created"
{ "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:
```bash
export BUCKET_ID="7c1d5e2a-4b8f-4f3a-9e61-2d0c8a5b3f17"
```
**Note.**
New buckets start private unless your workspace default is public (**Settings > Workspace > New files start as**). Private files need a signed link, so the upload step below makes this one file public explicitly.
#### 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.
```bash title="curl"
# 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"
```
```ts title="JavaScript"
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-...
```
```python title="Python"
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"])
```
```bash title="CLI"
steadylink upload ./hero.webp --bucket marketing --public
# hero.webp https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b
```
The completion call returns `202 Accepted` with the session in `committing`. Polling the batch shows it move through `scanning` to `ready`:
```json title="Response" status="200 OK"
{
"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](https://steadylink.io/docs/guides/upload-and-replace#what-the-upload-states-mean) lists every state.
With curl and Python, make the file public now. The JavaScript `visibility` option and the CLI `--public` flag already did this.
```bash title="curl"
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"
```
```python title="Python"
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:
```text
https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b
```
Open it in a browser or fetch the headers. No API key is involved: public delivery is anonymous.
```bash
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.
```bash title="curl"
# 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"
```
```ts title="JavaScript"
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-...
```
```python title="Python"
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"]) # 2
```
```bash title="CLI"
steadylink 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
```
```json title="Response" status="200 OK"
{ "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:
```bash
curl -I "https://cdn.steadylink.io/a/$ASSET_ID?v=2"
```
```text
HTTP/2 200
content-type: image/webp
cache-control: public, max-age=31536000, immutable, stale-while-revalidate=604800, stale-if-error=604800, no-transform
```
Use `?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](https://steadylink.io/docs/concepts/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
- [Upload and replace files](https://steadylink.io/docs/guides/upload-and-replace): Batches, scanning states, failure modes, and every way to publish a new version.
- [How stable links work](https://steadylink.io/docs/concepts/stable-links): Current and pinned revisions, caching, and what changes when you replace a file.
- [Image transformations](https://steadylink.io/docs/guides/image-transformations): Resize, crop, and convert the same link with query parameters.
- [Private links](https://steadylink.io/docs/security/private-links): Share private files with signed, expiring, revocable links.
### Guides
Source: https://steadylink.io/docs/guides
Each guide follows one task from start to finish. Most start in the dashboard and show the API, SDK, or CLI equivalent where one exists.
#### Files and links
- [Upload and replace files](https://steadylink.io/docs/guides/upload-and-replace): Batch uploads, scanning, and publishing a new version behind the same link.
- [Buckets and folders](https://steadylink.io/docs/guides/buckets-and-folders): Decide where files live and how to organize them without changing links.
- [Image transformations](https://steadylink.io/docs/guides/image-transformations): Resize, crop, and convert images from the delivery URL.
- [Collections and portals](https://steadylink.io/docs/guides/collections-and-portals): Share a curated set of files through a branded portal.
- [Client proofing and watermarks](https://steadylink.io/docs/guides/client-proofing): Let clients pick favorite photos, and watermark what they see.
#### Working with other people
- [Replacement requests](https://steadylink.io/docs/guides/replacement-requests): Let someone outside your workspace send a new version, then approve or schedule it.
- [Asset requests](https://steadylink.io/docs/guides/asset-requests): Collect a defined set of files through one expiring link.
- [Upload widget](https://steadylink.io/docs/guides/upload-widget): Accept files from your own website with an origin-restricted embed.
- [Notifications](https://steadylink.io/docs/guides/notifications): Choose which events reach you, and where.
#### Building and migrating
- [Browser uploads](https://steadylink.io/docs/guides/browser-uploads): Let users upload straight to storage without exposing your API key.
- [Images in Next.js](https://steadylink.io/docs/guides/nextjs-images): Serve resized images through `next/image` from links that never break.
- [Website migration](https://steadylink.io/docs/guides/website-migration): Scan a site, pick its media, and import it with a URL map.
- [File conversions](https://steadylink.io/docs/guides/file-conversions): Convert between formats and save results to a bucket.
#### Sync and backup
- [Connect rclone in one command](https://steadylink.io/docs/guides/rclone): Install rclone, add your app key, and mount a drive or schedule a backup.
#### AI assistants
- [Use SteadyLink in Claude, ChatGPT, Cursor and more](https://steadylink.io/docs/guides/mcp): Connect an AI app once, then upload files and get permanent links from a chat.
- [Set up SteadyLink with an AI assistant](https://steadylink.io/docs/guides/ai-assistants): Point ChatGPT, Claude or Cursor at llms-full.txt so it works from the real docs.
### Upload and replace files
Source: https://steadylink.io/docs/guides/upload-and-replace
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:
1. **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.
2. **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.
3. **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
1. Choose **Upload** from anywhere in the dashboard, or drag files onto an open bucket in **Files**.
2. Pick the **Destination** bucket and, optionally, a folder. If your workspace has no buckets yet, one called **My files** is created for you.
3. 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**.
4. 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.
```bash title="curl"
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.
```
```ts title="JavaScript"
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);
```
```python title="Python"
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"])
```
```bash title="CLI"
# 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/launch
```
The 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](https://steadylink.io/docs/reference/api/uploads).
##### 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](https://steadylink.io/docs/concepts/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](https://steadylink.io/docs/reference/api/buckets) 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](https://steadylink.io/blog/replace-image-without-changing-url) and [replacing a file while keeping the same URL](https://steadylink.io/blog/replace-file-keep-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
```bash title="curl"
# 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"
```
```ts title="JavaScript"
// 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);
```
```python title="Python"
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"])
```
```bash title="CLI"
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
```
```json title="Response" status="200 OK"
{ "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=4` are separate cache entries and show the new revision immediately. See [How stable links work](https://steadylink.io/docs/concepts/stable-links).
- **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 Locked` rather 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 `404` until you restore an earlier clean revision. Restore one from **Version history** right away. See [The scan window after a replacement](https://steadylink.io/docs/concepts/stable-links#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-temp` URL 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:
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/$ASSET_ID/versions/3/promote" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.rollback(assetId, 3);
```
```bash title="CLI"
steadylink versions 3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b
steadylink rollback 3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b 3
```
Only 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](https://steadylink.io/docs/concepts/revisions) covers labels, retention, and deleting revisions.
#### Next steps
- [Revisions](https://steadylink.io/docs/concepts/revisions): How revisions are numbered, retained, labeled, pinned, and deleted.
- [Uploads API](https://steadylink.io/docs/reference/api/uploads): Every upload route with request and response examples.
- [Replacement requests](https://steadylink.io/docs/guides/replacement-requests): Let someone outside your workspace send the new version for you to approve.
- [Browser uploads](https://steadylink.io/docs/guides/browser-uploads): Let your users upload straight to storage from your own web app.
### Buckets and folders
Source: https://steadylink.io/docs/guides/buckets-and-folders
This guide helps you set up a structure that still makes sense in a year: when to create a separate bucket, when a folder is enough, how to rename and move files without breaking a single link, and how default visibility is decided for new files. It also covers deleting, which is the one operation in this area that cannot be undone.
#### Where structure matters, and where it does not
A file's link is `https://cdn.steadylink.io/a/{asset_id}`. The bucket, the folder, and the filename are not part of it. That has two consequences:
- You can reorganize freely. Renaming a file, moving it to another folder, or renaming a folder never changes a link.
- Structure is for people and tools, not for delivery. Pick buckets and folders that make files easy to find, review, and automate against.
The path inside a bucket (for example `campaign/launch/hero.webp`) is called the file's **key**. The API, SDKs, and CLI use the key to address a file by location; the CLI writes it as `bucket:key`, such as `marketing:campaign/launch/hero.webp`.
#### Choose between a bucket and a folder
A **bucket** is a top-level container with its own settings. A **folder** is a path inside a bucket.
| Use a separate bucket when | Use a folder when |
| --- | --- |
| Files need different defaults: public for a website, private for client deliverables. | Files share the same defaults and owners. |
| You want different upload rules, such as a size limit or allowed file types for an intake bucket. | You are grouping by campaign, product, release, or date. |
| An automation should only ever touch one area, such as a CI job that publishes to `releases`. | People browse the files together. |
| You want storage totals reported separately. | You expect to reorganize later. |
A good default is one bucket per audience or system (`website`, `client-deliveries`, `releases`) and folders for everything else. You can always move files between folders later; you cannot move a file to another bucket without uploading it again under a new asset ID.
#### Create a bucket
In the dashboard, open **Files**, choose **New bucket**, and enter a name such as `marketing-assets`. Your first upload creates a bucket called **My files** automatically if you have none.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/assets/ \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Marketing assets", "slug": "marketing"}'
```
```ts title="JavaScript"
const { id } = await steadylink.createBucket("Marketing assets", "marketing");
const bucket = await steadylink.findBucket("marketing"); // ID, slug, or name
```
```python title="Python"
bucket = client.request("POST", "/api/assets/", {"name": "Marketing assets", "slug": "marketing"})
print(bucket["id"])
```
```json title="Response" status="201 Created"
{ "id": "7c1d5e2a-4b8f-4f3a-9e61-2d0c8a5b3f17" }
```
The `slug` is optional. It gives scripts and the CLI a readable handle (`--bucket marketing`) that does not depend on the display name. Buckets cannot be renamed after creation, so pick a name that will last; it never appears in a link. API keys need the `assets:write` scope to create buckets.
##### Bucket settings
Open a bucket's menu in **Files** and choose **Bucket settings**:
| Setting | Effect |
| --- | --- |
| **Public by default** | New uploads into this bucket get a public link. Off means new uploads are private. Overrides the workspace default for this bucket. |
| **Block disguised files** | Rejects a file whose contents do not match its declared type. |
| **Download HTML files** | Serves HTML files as downloads instead of rendering them in the browser. |
| **Maximum upload size (MB)** | A per-bucket cap below your plan's limit. Larger files end as `blocked` with code `policy`. |
The same settings, plus allowed file types, can be set with `POST /api/assets/{bucket_id}/settings`. See the [Buckets API](https://steadylink.io/docs/reference/api/buckets).
#### Work with folders
Folders appear as soon as a file is uploaded into a path, so you rarely need to create one first. To create an empty folder, open a bucket or folder and choose **New folder**. Move between levels with the breadcrumb, and switch between **List view** and **Gallery view** at any level.
```bash title="curl"
# Create an empty folder
curl -X POST "https://api.steadylink.io/api/assets/$BUCKET_ID/folders?path=campaign/launch" \
-H "X-API-Key: $STEADYLINK_API_KEY"
# List what is directly inside it
curl "https://api.steadylink.io/api/assets/$BUCKET_ID/objects?prefix=campaign/launch/" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.createFolder(bucketId, "campaign/launch");
const { folders, items } = await steadylink.listFiles(bucketId, { prefix: "campaign/launch/" });
for (const file of items) console.log(file.key, file.objectAssetId);
```
```python title="Python"
client.request("POST", f"/api/assets/{bucket_id}/folders?path=campaign/launch")
listing = client.request("GET", f"/api/assets/{bucket_id}/objects?prefix=campaign/launch/")
```
```bash title="CLI"
# The CLI creates folders implicitly when you upload with --folder
steadylink ls marketing:campaign/launch/
```
A listing returns the folders and files *directly* inside the prefix, not the whole subtree. To find a file anywhere, use **Search files** in the dashboard or `GET /api/assets/_search?q=hero` (add `scope=bucket&bucket_id=...` to search one bucket).
#### Rename and move files
Renaming and moving are the same operation: the file gets a new key in the same bucket. Its asset ID, every revision, its visibility, and its link are unchanged. Only the listing entry moves; no bytes are copied.
In the dashboard, open the file's menu and choose **Rename** or **Move**, then pick the **Destination folder** (or **Top level**).
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/$BUCKET_ID/objects/rename?from_key=hero.webp&to_key=campaign/launch/hero.webp" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.renameFile(bucketId, "hero.webp", "campaign/launch/hero.webp");
```
```python title="Python"
from urllib.parse import urlencode
query = urlencode({"from_key": "hero.webp", "to_key": "campaign/launch/hero.webp"})
client.request("POST", f"/api/assets/{bucket_id}/objects/rename?{query}")
```
```json title="Response" status="200 OK"
{ "renamed": true }
```
If a file already exists at `to_key`, the request fails with `409` and nothing changes; rename or remove the other file first. Two things to keep in mind after a move:
- Scripts that address the file by key (`marketing:hero.webp`) must use the new key. Anything that uses the asset ID or the link is unaffected.
- A later upload to the *old* key creates a brand-new file with a new asset ID, because that key is now free.
#### Rename and move folders
Renaming a folder changes the key of every file inside it in one step. As with files, asset IDs and links do not change. In the dashboard, open the folder's menu and choose **Rename** or **Move**.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/$BUCKET_ID/folders/rename?from_path=campaign/launch&to_path=campaigns/2026/launch" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
`/folders/move` accepts the same parameters. A folder cannot be moved into its own subtree (`400`). The SDKs and CLI do not wrap folder renames; call the route directly.
#### Control visibility
Every file is either **public** (anyone with the link can open it) or **private** (it opens only with a signed link). How a file gets its visibility:
1. **At first upload.** A new file takes the bucket's **Public by default** setting. If the bucket has never had that setting saved, it takes the workspace setting **New files start as** under **Settings > Workspace**. A workspace that never chose a default treats new files as private.
2. **When you change it.** Choose **Make public** or **Make private** from the file's menu, or set it with the API. The uploader in the dashboard and the SDK's `visibility` option set it right after upload.
3. **Never implicitly afterwards.** Changing a bucket or workspace default later affects only files uploaded after the change. Existing files keep their visibility, and replacing a file keeps its visibility too.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/$BUCKET_ID/objects/visibility?key=campaign/launch/hero.webp&visibility=private" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.setVisibility(bucketId, "campaign/launch/hero.webp", "private");
```
```python title="Python"
client.request("POST", f"/api/assets/{bucket_id}/objects/visibility?key=campaign/launch/hero.webp&visibility=private")
```
```bash title="CLI"
# The CLI sets visibility at upload time
steadylink upload ./hero.webp --bucket marketing --folder campaign/launch --private
```
The API also accepts `visibility=inherit`, which clears the file's own setting so it follows the bucket's privacy flag (`isPrivate` in the bucket listing). Making a file private takes effect at SteadyLink immediately, but a public copy fetched in the last five minutes can still be cached by browsers and edges; see [Private links](https://steadylink.io/docs/security/private-links) for sharing private files.
#### Delete files, folders, and buckets
**Warning.**
Deletion is immediate and permanent. There is no trash, restore endpoint, or grace period, and SteadyLink support cannot recover deleted files.
| Delete | What happens |
| --- | --- |
| **A file** | Every revision and every cached image variant is deleted, the storage is released, and the link starts returning `404`. Choose **Delete file** in the dashboard, `DELETE /api/assets/{bucket_id}/objects?key=...`, `steadylink.deleteFile()`, or `steadylink rm` (which asks first unless you pass `--yes`). |
| **A folder** | The folder and the files listed in it are removed from the bucket. Choose **Delete folder**, or `DELETE /api/assets/{bucket_id}/folders?path=...`. When the goal is to make specific links stop working, delete those files directly. |
| **A bucket** | Every file in it is deleted with all revisions and variants, every link to those files returns `404`, and the bucket's storage is released. Choose **Delete bucket**, `DELETE /api/assets/{bucket_id}`, or `steadylink.deleteBucket()`. |
Browsers and edges that fetched a public file in the last few minutes can keep showing their cached copy until it expires, even after the link starts returning `404`.
If you only want to stop serving an old version, delete or replace that revision instead of the file; see [Revisions](https://steadylink.io/docs/concepts/revisions). If you want a file to stop being public but keep it, make it private.
#### Next steps
- [Upload and replace files](https://steadylink.io/docs/guides/upload-and-replace): Batch uploads, scanning states, and publishing new versions.
- [Buckets API](https://steadylink.io/docs/reference/api/buckets): Every bucket route with request and response examples.
- [Private links](https://steadylink.io/docs/security/private-links): Share private files with expiring, revocable signed links.
### Image transformations
Source: https://steadylink.io/docs/guides/image-transformations
Use this guide when you want one uploaded image to serve many sizes: a 1200-pixel hero, a 400-pixel card thumbnail, and a 1200×630 social preview, all from the same stable link. You add query parameters to the link, SteadyLink generates the variant on first request, and the stored original is never modified. Because the parameters ride on the stable link, every variant follows the file when you replace it.
```text
https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b?w=1200&h=630&fit=cover&fm=webp&q=80
```
#### Build a transformed link in the dashboard
Open an image, go to the **Transform** tab, and set **Width**, **Height**, **Format**, and **Quality**. Choose **Copy link** to copy the transformed URL. The image is processed when the link is first opened, not when you copy it. For other file types the tab shows that transforms are not available; use [File conversions](https://steadylink.io/docs/guides/file-conversions) to turn a document or video into another format instead.
#### Parameters
| Parameter | Values | Default | Notes |
| --- | --- | --- | --- |
| `w` (or `width`) | 1 to 8000 | none | Target width in pixels. |
| `h` (or `height`) | 1 to 8000 | none | Target height in pixels. Give only one of `w` and `h` to keep the aspect ratio. |
| `fit` | `cover`, `contain`, `inside`, `outside` | `contain` | How the image fits a `w` × `h` box. See [Choose a fit](#choose-a-fit). |
| `fm` (or `format`) | `webp`, `jpg` (or `jpeg`), `png` | `jpg` | Output format. AVIF is not supported; `fm=avif` returns `400`. |
| `q` (or `quality`) | 30 to 95 | 82 | Output quality for `webp` and `jpg`. Ignored for `png`. |
| `focus` | `center`, `auto` | `center` | `auto` picks the most detailed region of the image for a `cover` crop. |
| `fp-x`, `fp-y` | 0 to 1 | none | An explicit focal point for a `cover` crop, as fractions of width and height. Send both. |
| `dpr` | 0.1 to 3 | 1 | Scales the original's dimensions when neither `w` nor `h` is set. Has no effect when `w` or `h` is set, so multiply the width yourself for high-density screens. |
| `allowUpscale` | `true`, `false` | `false` | Lifts the cap that stops a `cover` crop from enlarging the source more than 2×. |
| `v` | revision number | current | Pins the variant to one revision. See [Caching and replacements](#caching-and-replacements). |
Some rules apply to every request:
- **Transforms run only for raster images**: JPEG, PNG, WebP, GIF, BMP, and TIFF. Asking for a size or format on an SVG, PDF, video, or other file returns `415`. The original is still available without parameters.
- **A transform needs `w`, `h`, `fm`, or `dpr`.** Without one of them the original is served unchanged, so `?q=60` on its own does nothing.
- **Strict validation.** An out-of-range value, an unknown parameter, the same parameter twice, or more than 8 transform parameters returns `400` with a message naming the problem. The only other parameters a delivery URL accepts are `v`, `token` (for [private files](#private-images)), `preset`, and the traffic-source tags `ref` and `utm_*`. Do not append your own cache busters.
- **Aspect ratio.** When you send both `w` and `h`, the ratio must stay between 1:50 and 50:1.
#### Choose a format
If you resize without `fm`, the output is **JPEG**, whatever the source format. Always send `fm` when the source has transparency or when you want WebP.
- `fm=webp` is the best default for photos and illustrations on the web: smaller than JPEG at the same `q`.
- `fm=jpg` is the safest choice for email clients and older software.
- `fm=png` is lossless and larger; `q` has no effect.
PNG and WebP images with a full alpha channel are converted to RGB when transformed, so their transparency is lost even with `fm=png`. An animated GIF becomes a single frame; resize GIFs with `fm=png` or `fm=webp` rather than the JPEG default. For logos and icons that rely on transparency, serve the original without parameters, or check the transformed result before you publish it.
#### Choose a fit
`fit` only matters when you send both `w` and `h`.
- **`cover`** fills the box exactly, cropping whatever does not fit. Use it for thumbnails, cards, and social images that must be an exact size. The crop is centered unless you set a focal point or `focus=auto`.
- **`contain`**, **`inside`**, and **`outside`** currently behave the same way: the image is scaled down to fit inside the box, keeping its aspect ratio and without cropping. The result can be smaller than the box in one dimension. These modes never enlarge an image.
```text
# Exactly 400 × 400, cropped around the most detailed area
https://cdn.steadylink.io/a/3f2a9c1e-...?w=400&h=400&fit=cover&focus=auto&fm=webp
# At most 1600 × 900, aspect ratio kept, never cropped
https://cdn.steadylink.io/a/3f2a9c1e-...?w=1600&h=900&fm=webp
```
#### Control the crop with focal points
A `cover` crop that cuts off someone's face is the most common problem with automatic resizing. There are three ways to steer it, in order of precedence:
1. **`fp-x` and `fp-y` in the URL.** Explicit coordinates for this one link that say which part of the image the crop keeps: `0,0` keeps the top-left, `1,1` the bottom-right, and `0.5,0.5` the center. For a subject 42% from the left and 31% from the top, use `fp-x=0.42&fp-y=0.31`.
2. **`focus=auto` in the URL.** SteadyLink finds the most detailed region of the image and positions the crop toward it. It is deterministic: the same image always produces the same crop.
3. **A saved focal point on the file.** Applied to every `cover` request for that file that does not specify its own `fp-x`/`fp-y` or `focus=auto`.
You cannot combine `focus=auto` with `fp-x`/`fp-y`, and both require `fit=cover`; otherwise the request returns `400`.
##### Save a focal point
A saved focal point lets every existing `cover` link crop correctly without editing any URL. Set it with the API, the SDKs, or the CLI:
```bash title="curl"
curl -X PUT "https://api.steadylink.io/api/platform/assets/$ASSET_ID/focal-point" \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"x": 0.42, "y": 0.31}'
```
```ts title="JavaScript"
await steadylink.setFocalPoint(assetId, 0.42, 0.31);
```
```python title="Python"
client.set_focal_point(asset_id, 0.42, 0.31)
```
```bash title="CLI"
steadylink focal 3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b --x 0.42 --y 0.31
```
```json title="Response" status="200 OK"
{ "x": 0.42, "y": 0.31 }
```
Coordinates are stored to four decimal places. Clear the saved point with `DELETE /api/platform/assets/{asset_id}/focal-point` (`204 No Content`). Because the route is under `/api/platform`, an API key needs the `workspace:admin` scope to change it; a signed-in member needs permission to change files.
The focal point belongs to the file, not to a revision, so it survives replacements. If a new version has a different composition, update the focal point when you replace it.
#### Caching and replacements
Each variant is generated once per revision and stored, so the first request for a new combination of parameters is slower than the ones after it. On top of that, delivery responses are cached by browsers and edges by URL:
| URL | Cache lifetime | After a replacement |
| --- | --- | --- |
| Without `v`: `?w=800&fm=webp` | 5 minutes, then refreshed in the background | Serves a variant of the new revision once the cached copy expires. |
| With `v`: `?v=3&w=800&fm=webp` | 1 year, immutable | Always serves revision 3. |
Use links without `v` anywhere the image should follow replacements, which is almost always. Use `v` when a page must keep showing exactly one version, for example an archived press release, and accept that it never updates.
Some details that matter at scale:
- **Equivalent URLs share one variant.** Defaults are filled in before a variant is looked up, so `?w=800` and `?w=800&fit=contain&q=82` produce the same stored image. They are still different URLs for browser and edge caches.
- **Variant limits.** To stop runaway URL generation, SteadyLink caps how many distinct variants one file and one workspace can create (by default 100 per file and 10,000 per workspace). A request for a new variant beyond the cap returns `429` with code `asset_transform_cardinality_exceeded` or `workspace_transform_cardinality_exceeded`; variants that already exist keep working. Generate a small, fixed set of sizes instead of arbitrary widths.
- **Changing a focal point** produces new variants on the next request for each `cover` URL, subject to the five-minute cache.
- **Clearing variants.** `POST /api/assets/{asset_id}/purge` deletes the stored variants of every revision of a file; they are regenerated on demand. Deleting a revision deletes its variants.
#### Named presets
A preset stores a set of parameters under a slug so links stay short and you can change a size in one place. Create one with the API; workspace admins (or an API key with `assets:write`) can manage presets.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/usage/transform-presets \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Card thumbnail", "slug": "card", "params": {"w": 400, "h": 300, "fit": "cover", "fm": "webp"}, "isPublic": true}'
```
Then request `https://cdn.steadylink.io/a/{asset_id}?preset=card`. Only presets created with `isPublic: true` can be used in delivery URLs; any other slug returns `404`. A preset cannot be combined with other transform parameters (only with `v`), and the slug must be lowercase letters, digits, and hyphens. Leave `fp-x`/`fp-y` out of presets: once the defaults are filled in, a preset with focal coordinates can exceed the 8-parameter limit and fail at delivery. Use `focus=auto` or the file's saved focal point instead.
#### Build links in code
The SDK and CLI build the same URLs for you, so you never concatenate query strings by hand.
```ts title="JavaScript"
import { assetUrl } from "@steadylink/sdk";
steadylink.link(assetId, { width: 1200, height: 630, fit: "cover", focus: "auto", format: "webp", quality: 80 });
steadylink.link(assetId, { width: 400, height: 400, fit: "cover", focalPoint: { x: 0.42, y: 0.31 } });
// Without a client, for example in browser code
assetUrl(assetId, { width: 800, format: "webp" });
```
```bash title="CLI"
steadylink link 3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b --w 1200 --h 630 --fit cover --fm webp --q 80
```
The SDK clamps `quality` to 30–95 and focal coordinates to 0–1 before building the URL. For `next/image`, use the loader described in [Images in Next.js](https://steadylink.io/docs/guides/nextjs-images).
#### Private images
Transforms work on private files too. Add the signed link's `token` alongside the transform parameters:
```text
https://cdn.steadylink.io/a/3f2a9c1e-...?token=eyJhbGciOi...&w=800&fm=webp
```
Private responses, transformed or not, are sent with `Cache-Control: private, no-store`, so they are never stored by shared caches and every request reaches SteadyLink. See [Private links](https://steadylink.io/docs/security/private-links).
#### Troubleshooting
| Response | Likely cause |
| --- | --- |
| `400 Unsupported format` | `fm` is not `webp`, `jpg`, `jpeg`, or `png`. |
| `400 Quality must be between 30 and 95` | `q` is out of range. |
| `400 Focal controls require fit=cover` | `focus=auto` or `fp-x`/`fp-y` without `fit=cover`. |
| `400 Unknown params: ...` | A parameter SteadyLink does not recognize, often a cache buster or a typo. |
| `400 Conflicting parameters: w and width` | The same setting sent under both its short and long name. |
| `415 Transforms supported only for raster images` | The file is not a JPEG, PNG, WebP, GIF, BMP, or TIFF. |
| `423 Scanning` | The current revision is still being scanned. Retry shortly. |
| `429` with a `*_transform_cardinality_exceeded` code | The file or workspace reached its variant limit. |
#### Next steps
- [Images in Next.js](https://steadylink.io/docs/guides/nextjs-images): Serve responsive images through `next/image` from stable links.
- [Delivery API](https://steadylink.io/docs/reference/api/delivery): The full delivery reference, including signed links and response headers.
- [How stable links work](https://steadylink.io/docs/concepts/stable-links): Current and pinned revisions, and how caching follows replacements.
### Collections and portals
Source: https://steadylink.io/docs/guides/collections-and-portals
A collection is a named, ordered list of files that already live in your workspace. Publishing it gives you a portal at `https://steadylink.io/c/{slug}` where recipients can preview, search, and download those files without a SteadyLink account. This guide is for anyone assembling a press kit, a brand asset pack, or a hand-off folder for a client. By the end you will have a collection with the right access mode, the files in the right order, and a clear idea of what your recipients see.
#### How a collection relates to your files
A collection holds references, not copies. Adding a file does not duplicate its bytes or use more storage, and removing it from the collection (or deleting the whole collection) leaves the file where it was in its bucket.
Each item resolves to one revision of its file at the moment someone opens the portal:
- **Following current** (the default): the portal always shows the file's current revision. When you [replace the file](https://steadylink.io/docs/guides/upload-and-replace), the portal shows the new version on the next page load, with nothing to update.
- **Pinned**: the item stays on one specific revision number even after the file is replaced. Use this when the collection documents a fixed release, such as "the logo files we approved for the Q3 campaign."
A portal only lists items whose resolved revision has passed malware scanning. A freshly replaced file that is still being scanned drops out of a following-current portal until the scan finishes clean, and a file that fails scanning never appears.
#### Create a collection
**Start a new collection**
Open **Share** in the dashboard sidebar, stay on the **Collections** tab, and choose **New collection**.
**Name it and choose access**
Enter a name (up to 200 characters) and an optional description (up to 2,000 characters). The name also seeds the portal address: "Press kit 2026" becomes something like `/c/press-kit-2026-4f9a1c`. The six-character suffix is random, so two collections with the same name never collide. Pick an access mode, described in the next section.
**Choose files**
Select the files to include and save. Files are added in the order you select them and new files are always appended to the end.
**Share the link**
Choose **Copy link** on the collection row. Anyone you send it to sees the portal according to its access mode.
**Note.**
The portal address is fixed when the collection is created. Renaming the collection later changes the title recipients see but not the URL, so links you have already sent keep working.
#### Choose an access mode
Every collection has exactly one access mode.
| Mode | Who can open the portal | Requirements |
| --- | --- | --- |
| `private` | Nobody outside the workspace. The portal address returns `404 Not Found`. | None. Use it to stage a collection before sharing it. |
| `public` | Anyone with the link. | None. |
| `password` | Anyone with the link who enters the password. | A password of 8 to 128 characters. |
| `expiring` | Anyone with the link until the expiration time. | An expiration time in the future. |
A few behaviors are worth knowing before you choose:
- **A correct password grants one hour of access.** When a recipient enters the correct password, the portal receives an access token valid for one hour (or until the collection expires, if that is sooner). Changing the password means the old one no longer works, but tokens already issued stay valid until they run out. To cut off access immediately, switch the collection to `private`.
- **Expiration applies to every mode.** An expired collection returns `410 Gone` with "This collection link has expired" whether it is `expiring`, `public`, or `password`. The dashboard sets the expiration to the end of the day you pick.
- **Switching modes keeps the link.** You can move a collection from `private` to `public` or `password` at any time without changing its address.
- **Search engines are asked not to index portals.** New collections have `noindex` turned on, which sets the portal's robots metadata to `noindex, nofollow`. Turn it off through the API only for collections you want to appear in search results.
#### Arrange files
The dashboard adds and removes files. Ordering and pinning are done through the API or the TypeScript SDK.
- **Order**: send the complete list of item IDs in the order you want. The request must contain every item in the collection exactly once (up to 1,000 items), otherwise it fails with `422` and "Order must contain every collection item exactly once". Read the collection first to get the current item IDs.
- **Pin**: set an item's `version` to a revision number to pin it, or to `null` to make it follow the current revision again. A revision number that does not exist for that file returns `422 Revision not found`.
- **Duplicates**: a file can appear in a collection only once. Adding it again returns `409` with "This asset is already in the collection".
```bash title="curl"
# Create a password-protected collection
curl -X POST https://api.steadylink.io/api/collections \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Press kit 2026",
"description": "Logos, product photos, and the fact sheet.",
"accessMode": "password",
"password": "launch-week-2026"
}'
# Add a file that follows its current revision
curl -X POST https://api.steadylink.io/api/collections/8d1e5b72-3c4a-4f0e-9b6d-2a7c1e9f4d30/items \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "assetId": "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d" }'
# Add a file pinned to revision 4
curl -X POST https://api.steadylink.io/api/collections/8d1e5b72-3c4a-4f0e-9b6d-2a7c1e9f4d30/items \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "assetId": "b71c0d2e-1f3a-4c5b-8d9e-0a1b2c3d4e5f", "version": 4 }'
```
```ts title="JavaScript"
import { SteadyLink } from "@steadylink/sdk";
const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! });
const kit = await steadylink.createCollection({
name: "Press kit 2026",
description: "Logos, product photos, and the fact sheet.",
accessMode: "password",
password: "launch-week-2026",
});
await steadylink.addToCollection(kit.id, "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d"); // follows current
await steadylink.addToCollection(kit.id, "b71c0d2e-1f3a-4c5b-8d9e-0a1b2c3d4e5f", 4); // pinned to revision 4
// Put the fact sheet first
const { items = [] } = await steadylink.getCollection(kit.id);
const ids = items.map((item) => item.id as string);
await steadylink.reorderCollection(kit.id, [ids[1], ids[0]]);
console.log(kit.url); // https://steadylink.io/c/press-kit-2026-4f9a1c
```
```python title="Python"
import os
from steadylink import SteadyLink
client = SteadyLink(api_key=os.environ["STEADYLINK_API_KEY"])
kit = client.request("POST", "/api/collections", {
"name": "Press kit 2026",
"description": "Logos, product photos, and the fact sheet.",
"accessMode": "password",
"password": "launch-week-2026",
})
client.request("POST", f"/api/collections/{kit['id']}/items", {"assetId": "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d"})
client.request("POST", f"/api/collections/{kit['id']}/items", {"assetId": "b71c0d2e-1f3a-4c5b-8d9e-0a1b2c3d4e5f", "version": 4})
```
```json title="Response" status="201 Created"
{
"id": "8d1e5b72-3c4a-4f0e-9b6d-2a7c1e9f4d30",
"name": "Press kit 2026",
"description": "Logos, product photos, and the fact sheet.",
"slug": "press-kit-2026-4f9a1c",
"accessMode": "password",
"expiresAt": null,
"noindex": true,
"viewCount": 0,
"downloadCount": 0,
"itemCount": 0,
"createdAt": "2026-10-08T14:02:11.402913",
"updatedAt": "2026-10-08T14:02:11.402913",
"canCustomizeBranding": false,
"branding": {
"name": null,
"logoUrl": null,
"url": null,
"primaryColor": null,
"accentColor": null,
"removeSteadyLinkBranding": false
}
}
```
The Python package has no collection helpers, so the example uses its generic `request` method. The [TypeScript SDK](https://steadylink.io/docs/reference/sdk) has one method per operation:
| Method | What it does |
| --- | --- |
| `listCollections()` | Every collection in the workspace, most recently updated first. |
| `createCollection(input)` | Creates a collection. Accepts `name`, `description`, `accessMode`, `password`, `expiresAt`, and `noindex`. |
| `getCollection(id)` | Settings plus `items` in portal order, each with `version` and `pinned`. |
| `updateCollection(id, changes)` | Changes any of the create fields. |
| `deleteCollection(id)` | Deletes the collection. Its link stops working; the files stay. |
| `addToCollection(id, assetId, version?)` | Appends a file, optionally pinned. |
| `removeFromCollection(id, itemId)` | Removes one item. |
| `pinCollectionItem(id, itemId, version)` | Pins to a revision, or `null` to follow current. |
| `reorderCollection(id, itemIds)` | Replaces the full order. |
Every collection the SDK returns carries a `url` built from the client's `appUrl` option (default `https://steadylink.io`) and the slug.
#### What recipients see
The portal shows the collection name and description, then one card per file with its name, type, and size. Images show a preview that opens full size in a new tab; other files show a type icon. Each file has a **Download** button unless you turn downloads off. Collections with 12 or more files also get a search box that filters by file name or type. To let clients pick favorites or to watermark images, see [Client proofing and watermarks](https://steadylink.io/docs/guides/client-proofing).
Behind the scenes:
- Previews and downloads are served through short-lived links (five minutes) generated for each request, so a copied download URL stops working quickly. Encrypted files are decrypted on the server before they are sent.
- Opening a preview does not count as a download. Loading the portal counts as one view.
- Portal previews and downloads count toward your workspace's monthly [delivery usage](https://steadylink.io/docs/concepts/usage-and-limits), the same as delivery-link traffic.
#### Brand the portal
On Free and Personal plans the portal shows your workspace name, SteadyLink's default colors, and a SteadyLink credit. On **Pro, Business, and Enterprise** plans with an active subscription you can set these per collection through the API:
| Field | Rules |
| --- | --- |
| `brandName` | Up to 200 characters. Replaces the workspace name in the portal header. |
| `brandLogoUrl` | An `https://` URL to your logo. |
| `brandUrl` | An `https://` URL the portal links to, such as your website. |
| `primaryColor`, `accentColor` | Six-digit hex values such as `#0b2545`. Three-digit shorthand is rejected. |
| `removeSteadyLinkBranding` | `true` hides the SteadyLink credit on the portal. |
Sending any of these fields on another plan returns `403` with "Custom portal branding is available on Pro and Business plans". The `canCustomizeBranding` field on every collection tells you in advance whether the workspace qualifies. If a subscription lapses, the portal falls back to the workspace name and default colors and shows the SteadyLink credit again; the saved values return when the subscription is active.
```bash
curl -X PATCH https://api.steadylink.io/api/collections/8d1e5b72-3c4a-4f0e-9b6d-2a7c1e9f4d30 \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brandName": "Northwind Studio",
"brandLogoUrl": "https://northwind.example/logo.svg",
"brandUrl": "https://northwind.example",
"primaryColor": "#0b2545",
"accentColor": "#f4a259",
"removeSteadyLinkBranding": true
}'
```
#### Track views and downloads
The collection list in the dashboard shows each collection's file and view counts. For download counts and daily history, call `GET /api/collections/{collection_id}/analytics`. It returns the lifetime `viewCount` and `downloadCount` and a `days` array with views and downloads for each of the most recent 90 days that had activity. API keys need the `assets:read` scope for this route.
#### Limits
| Plan | Collections per workspace |
| --- | --- |
| Free | 3 |
| Personal | 15 |
| Pro | 100 |
| Business | 500 |
| Enterprise | 5,000 |
Creating a collection beyond the limit returns `409` with the code `feature_limit_reached`. Deleting a collection frees its slot. Paid limits apply only while the subscription is active; otherwise the workspace uses Free limits.
Creating, updating, and deleting collections or their items requires a workspace role that can edit files (member, admin, or owner) or an API key with `assets:write`.
#### Next steps
- [Sharing API](https://steadylink.io/docs/reference/api/sharing): Every collection, item, and public portal route with fields and responses.
- [Upload and replace files](https://steadylink.io/docs/guides/upload-and-replace): Publish a new version that every following-current portal picks up.
- [Revisions](https://steadylink.io/docs/concepts/revisions): How revision numbers work, which is what pinning refers to.
### Client proofing and watermarks
Source: https://steadylink.io/docs/guides/client-proofing
Proofing turns a [collection](https://steadylink.io/docs/guides/collections-and-portals) into a gallery where clients pick favorites. It is built for photographers and studios sending a shoot for selection, but it works for any set of images someone needs to choose from. By the end of this guide you will have a gallery that takes picks, optionally caps how many each client can choose, and shows watermarked images until you deliver the finals.
#### Turn on proofing
**Build the collection**
Create a collection in **Share** and add the photos, as described in [Collections and portals](https://steadylink.io/docs/guides/collections-and-portals). Any access mode works, including a password.
**Open its proofing settings**
Open the collection's actions menu and choose **Client proofing**, then the **Settings** tab.
**Let clients pick**
Turn on **Let clients pick favorites**. To cap the selection, enter a number under **Most photos a client can pick**. Leave it empty for no limit.
**Save and share the link**
Choose **Save settings**, then send the collection link as usual. Collections with proofing on show a **Proofing** badge in the list.
#### What your client sees
The gallery lays photos out in rows in the order you set. Each photo has a heart. The first time a client taps one, they are asked for their name and email, and from then on every heart saves as they go. A bar at the bottom shows how many they have picked, and how many they are allowed when you set a limit. Opening a photo shows it full screen with the heart and, when downloads are on, a download button. Clients can move between photos with the arrow keys or by swiping.
When they are done, the client chooses **Send picks**, adds an optional note, and sends. Their picks then lock so the selection you are working from can't change under you.
A few details worth knowing:
- **Picks follow the email, not the device.** If the client opens the gallery on another device and enters the same email, their picks are there. The earlier device is signed out of that selection.
- **Several people can pick.** Each email gets its own selection, so a couple or a marketing team can each send their own.
- **The limit is enforced.** Once a client reaches it, another heart shows a message asking them to remove one first.
#### Review picks
Open **Client proofing** on the collection and stay on the **Picks** tab. Every client who has started picking is listed with their email, how many photos they chose, and whether they have sent them. Selections that are still in progress show up too, so you can see where a client is before they finish.
Expand a selection to see the client's note and the photos they picked. **Copy filenames** copies the picked file names without extensions, separated by commas, ready to paste into a filename filter in your editing app.
From a selection's menu you can:
- **Let them change picks**: reopens a sent selection so the client can edit and send it again.
- **Delete picks**: removes the selection. The client can start over from the gallery.
When a client sends their picks, everyone in the workspace who has the **Client picks sent** notification turned on is told, in the app, by email, or by webhook depending on their [notification settings](https://steadylink.io/docs/guides/notifications). The notification links straight to the picks.
#### Turn off downloads
Turn off **Allow downloads** to let clients view and pick without saving files. The download buttons disappear from the gallery and the download route returns `403` with the code `downloads_off`. Previews keep working. This setting works whether or not proofing is on.
#### Add a watermark
Turn on **Watermark photos** and choose a text or logo mark. The preview in the settings uses one of the collection's own photos.
| Setting | Options |
| --- | --- |
| Type | **Text** (up to 60 characters) or **Logo** (an image file from this workspace, best as a PNG with a transparent background) |
| Position | One of nine positions, or **Repeat** to tile the mark diagonally across the photo |
| Opacity | 5% to 100% |
| Size | 5% to 80% of the photo's width |
The watermark is part of the image itself. Previews and downloads from the collection are both made from the original with the mark added on the server, so the unmarked original never reaches the client. Previews are at most 2,400 pixels on the longest edge, and downloads keep the original size. Both come back as JPEG, or PNG when the original has transparency.
**Note.**
Only JPEG, PNG, WebP, HEIC, HEIF, AVIF, and TIFF images get a watermark. Other files in the collection are shared as they are.
How marked copies are made:
- **On first view.** Each photo is marked the first time someone opens it, so that first view can take a moment. After that the marked copy is reused.
- **Per settings.** Changing the text, logo, position, opacity, or size makes new copies the next time each photo is opened. Older copies are never shown again.
- **Encrypted workspaces** never store a marked copy. The mark is added on every request instead.
The watermark covers this collection only. While it is on, the gallery leaves file IDs out of its responses so they can't lead to the files themselves. If you have shared a photo's own [delivery link](https://steadylink.io/docs/concepts/stable-links) somewhere else, that link still serves the original. Make the file private if that matters.
#### Do it through the API
Proofing settings are fields on the collection. Send any of them to `PATCH /api/collections/{collection_id}`:
```bash
curl -X PATCH https://api.steadylink.io/api/collections/8d1e5b72-3c4a-4f0e-9b6d-2a7c1e9f4d30 \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"proofingEnabled": true,
"selectionLimit": 25,
"downloadsEnabled": false,
"watermark": { "enabled": true, "kind": "text", "text": "© Northwind Studio", "position": "br", "opacity": 50, "size": 25 }
}'
```
Read the picks with `GET /api/collections/{collection_id}/selections`. The [Sharing API reference](https://steadylink.io/docs/reference/api/sharing#client-proofing) covers every field and route, including the public routes the gallery uses.
#### Next steps
- [Collections and portals](https://steadylink.io/docs/guides/collections-and-portals): Access modes, ordering, pinning, and portal branding.
- [Notifications](https://steadylink.io/docs/guides/notifications): Get told by email or webhook when a client sends their picks.
- [Sharing API](https://steadylink.io/docs/reference/api/sharing): Proofing fields, selections, and the public gallery routes.
### Replacement requests
Source: https://steadylink.io/docs/guides/replacement-requests
A replacement request is a single-purpose upload link for one existing file. You send it to an agency, a designer, or a colleague without a SteadyLink account; they upload a candidate; nothing changes on your live link until someone in your workspace approves it. Approval can publish immediately, at a scheduled time, or with an automatic rollback later. This guide walks through the whole loop and the API calls behind it.
Use a replacement request when you know which file needs updating and want the new version to keep the same stable link. To collect several new files at once, use an [asset request](https://steadylink.io/docs/guides/asset-requests) instead.
#### How it works
1. You create a request for one file. SteadyLink returns a secret link of the form `https://steadylink.io/replace/{token}`.
2. The recipient opens the link, sees the file name, your workspace name, and your note, and uploads a candidate. They cannot see the bucket, other files, or earlier revisions.
3. The candidate sits in temporary storage. The live file is untouched.
4. You approve, reject, or revoke. Approval creates a new revision of the file behind the same delivery URL, either live right away or on a schedule.
A request moves through these statuses:
| Status | Meaning |
| --- | --- |
| `pending` | Link created, nothing uploaded yet. |
| `submitted` | A candidate is waiting for review. The recipient can still upload a different file, which replaces the candidate. |
| `approved` | The candidate was published as the current revision. |
| `scheduled` | The candidate was approved for a future publish time. |
| `rejected` | The candidate was discarded. The link no longer works. |
| `revoked` | You cancelled the request. The link no longer works. |
#### Create a request
**Open replacement links**
In the dashboard, open **Requests**, switch to **Replacement links**, and choose **New replacement link**.
**Pick the file and add context**
Choose the file to replace. Optionally add a recipient name and a note, such as "Please send the final hero image at 2400 px wide." Both are shown to the recipient on the upload page.
**Copy the link**
Choose **Create**, then copy the link from the row. The dashboard creates links that last 7 days.
Through the API you can set the lifetime yourself with `expiresInDays`, from 1 to 30 (default 7). The response contains the secret `token` and `sharePath` exactly once.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/assets/3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d/replacement-requests \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipientName": "Avery at Northwind Studio",
"note": "Please send the final hero image at 2400 px wide.",
"expiresInDays": 14
}'
```
```ts title="JavaScript"
const request = await steadylink.request<{ id: string; sharePath: string }>(
"/api/assets/3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d/replacement-requests",
{
method: "POST",
body: JSON.stringify({
recipientName: "Avery at Northwind Studio",
note: "Please send the final hero image at 2400 px wide.",
expiresInDays: 14,
}),
},
);
const link = `https://steadylink.io${request.sharePath}`;
```
```python title="Python"
request = client.request(
"POST",
"/api/assets/3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d/replacement-requests",
{
"recipientName": "Avery at Northwind Studio",
"note": "Please send the final hero image at 2400 px wide.",
"expiresInDays": 14,
},
)
link = "https://steadylink.io" + request["sharePath"]
```
```json title="Response" status="200 OK"
{
"id": "c41f7e0a-9b2d-4a63-8e15-7d0c2b9a6f48",
"assetId": "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d",
"recipientName": "Avery at Northwind Studio",
"note": "Please send the final hero image at 2400 px wide.",
"status": "pending",
"filename": null,
"byteSize": null,
"mime": null,
"expiresAt": "2026-10-22T14:10:03.118204",
"submittedAt": null,
"decidedAt": null,
"createdAt": "2026-10-08T14:10:03.118204",
"token": "q8XbN2vR...",
"sharePath": "/replace/q8XbN2vR..."
}
```
The file must be stored in a bucket; a request for a file that is not connected to one returns `409`.
##### Lost the link
SteadyLink stores only a hash of the token, so it cannot show you the link again. Instead you generate a new one: in the dashboard, choose **Generate new link** from the row's menu; through the API, call `POST /api/assets/{asset_id}/replacement-requests/{request_id}/rotate-link`. The old link stops working the moment the new one is created, so send the new link to the recipient. Only `pending` and `submitted` requests that have not expired can get a new link.
#### What the recipient sees
The `/replace/` page names your workspace and the file ("Northwind requested a replacement for hero.webp"), shows your note and the recipient name, and offers a drop zone. The page tells the recipient that the current public file stays untouched until the upload is approved. After uploading they see **File sent for approval**, and they can choose **Submit a different file** until you make a decision; each new upload replaces the previous candidate.
Things that can go wrong for the recipient:
- **Request unavailable.** The link expired, was revoked, or the candidate was rejected. The public routes return `410 Gone` in all three cases and `404` for a token that never existed.
- **File too large.** A single candidate can be up to 2 GiB.
- **Size mismatch.** SteadyLink compares the uploaded bytes with the size the browser declared. An interrupted upload fails with "Uploaded file size does not match" and the recipient can try again.
#### Review the candidate
The **Replacement links** tab lists the latest requests in the workspace with their status. Approving, rejecting, and scheduling are done through the API. List an asset's requests with `GET /api/assets/{asset_id}/replacement-requests` (latest 50) or the whole workspace with `GET /api/assets/replacement-requests` (latest 200); a request ready for review has `status: "submitted"` and shows the candidate's `filename`, `byteSize`, and `mime`.
##### Approve and publish now
```bash
curl -X POST https://api.steadylink.io/api/assets/3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d/replacement-requests/c41f7e0a-9b2d-4a63-8e15-7d0c2b9a6f48/approve \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
```
```json title="Response" status="200 OK"
{ "approved": true, "version": 7, "scheduledJobId": null }
```
The candidate becomes revision 7 and the delivery URL starts serving it. Everything that points at the file, including following-current [collection](https://steadylink.io/docs/guides/collections-and-portals) items, picks it up.
##### Approve and publish later
Send `publishAt` to keep the current revision live until a specific time. Add `rollbackAt` to restore today's revision automatically afterwards, which suits a sale banner or an event poster.
```bash
curl -X POST https://api.steadylink.io/api/assets/3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d/replacement-requests/c41f7e0a-9b2d-4a63-8e15-7d0c2b9a6f48/approve \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"publishAt": "2026-11-27T05:00:00Z",
"rollbackAt": "2026-12-01T05:00:00Z"
}'
```
```json title="Response" status="200 OK"
{ "approved": true, "version": 7, "scheduledJobId": "5e9a0c3b-2d7f-4b18-a6e4-91c0d8f2b7a5" }
```
The rules for times:
- `publishAt` must be more than 10 seconds in the future and no more than one year away.
- `rollbackAt` must be after `publishAt`.
- Send times with an explicit offset, ideally UTC with a trailing `Z`.
A time outside these limits returns `422` with "Publish time must be at least 10 seconds from now", "Publish time must be within one year", or "Rollback time must be after the publish time".
With a schedule, the new revision is created right away and appears in the file's revision history, but the current revision does not change until `publishAt`. At that moment SteadyLink makes it current, warms the delivery cache for public files, and, if you set `rollbackAt`, queues a second job that restores the revision that was current when you approved. The request's status becomes `scheduled`.
You can also send only `rollbackAt`: the candidate goes live now and the previous revision comes back at `rollbackAt` (which must be at least 10 seconds ahead and within a year).
**Warning.**
A scheduled publication does not check whether the file changed in the meantime. If someone replaces the file after you schedule, the scheduled revision still becomes current at `publishAt`, and a rollback restores the revision that was current at scheduling time, not the newer one. Cancel the schedule before replacing the file if you no longer want it.
A scheduled revision only goes live once it has passed malware scanning. If scanning has not finished at `publishAt`, the job retries until it has.
##### Reject or revoke
- **Reject** (`POST .../reject`) discards a `submitted` candidate and closes the request. Use it when the upload is wrong; create a new request if you still need a file. Rejecting a request that has no candidate returns `409`.
- **Revoke** (`DELETE /api/assets/{asset_id}/replacement-requests/{request_id}`) closes the request in any state and deletes a waiting candidate. The dashboard's **Revoke** action does this.
In both cases the temporary upload is deleted and the link returns `410 Gone`.
#### Manage scheduled publications
Scheduled publications are jobs attached to the file, whether they came from a replacement request or from scheduling an existing revision directly with `POST /api/assets/{asset_id}/scheduled-revisions`:
```bash
# Schedule revision 12 of a file to become current, with no rollback
curl -X POST https://api.steadylink.io/api/assets/3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d/scheduled-revisions \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "version": 12, "publishAt": "2026-11-27T05:00:00Z", "rollbackAt": null }'
# List queued and running publications for the file
curl https://api.steadylink.io/api/assets/3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d/scheduled-revisions \
-H "X-API-Key: $STEADYLINK_API_KEY"
# Cancel one
curl -X DELETE https://api.steadylink.io/api/assets/3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d/scheduled-revisions/5e9a0c3b-2d7f-4b18-a6e4-91c0d8f2b7a5 \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
The list returns each job's `id`, `version`, `publishAt`, `rollbackAt`, and `status` (`queued` or `processing`). A job can be cancelled only while it is `queued`; once the worker has picked it up, cancelling returns `409` with "This scheduled revision can no longer be cancelled". Cancelling the publish job also prevents its rollback, because the rollback is only queued when the publication runs. To stop a rollback that is already queued, list the jobs again after `publishAt` and cancel the rollback job.
The revision you schedule must still exist at `publishAt`. If it has been deleted by then, or the file itself has been deleted, the job fails permanently and the current revision stays as it is. Scheduling a revision number that does not exist returns `404 Revision not found` straight away.
#### Permissions
Creating, approving, rejecting, revoking, and scheduling require a workspace role that can edit files (member, admin, or owner) or an API key with `assets:write`. Listing requests and schedules needs `assets:read`. The public `/api/replacement-requests/{token}` routes the recipient's browser uses are authorized by the token alone and never see your API key.
#### Next steps
- [Requests API](https://steadylink.io/docs/reference/api/requests): Exact fields and responses for replacement requests and scheduled revisions.
- [Revisions](https://steadylink.io/docs/concepts/revisions): How revisions, current versions, and rollbacks fit together.
- [Notifications](https://steadylink.io/docs/guides/notifications): Get told when a scheduled revision publishes or rolls back.
### Asset requests
Source: https://steadylink.io/docs/guides/asset-requests
An asset request (an "upload form" in the dashboard) is a link you send to a client, partner, or contributor so they can upload the files you asked for, without an account and without seeing anything else in your workspace. Each file they send waits in temporary storage until someone in your workspace approves it into a bucket, uses it to replace an existing file, or rejects it. This guide covers building the request, what the sender experiences, and how review decisions work.
To get a new version of one specific file, a [replacement request](https://steadylink.io/docs/guides/replacement-requests) is simpler. To accept uploads continuously from your own website, use the [upload widget](https://steadylink.io/docs/guides/upload-widget).
#### Plan the request
A request has a name, optional instructions, an expiration, and between 1 and 25 **requested files** (called fields in the API). Each requested file is one slot the sender fills with one upload, and each has its own rules:
| Rule | API field | Notes |
| --- | --- | --- |
| Label | `label` | What the sender sees, such as "Hero image". Up to 200 characters, cannot be blank. |
| Description | `description` | Optional per-file instructions, up to 2,000 characters. |
| Required | `required` | Defaults to `true`. The sender cannot submit until every required file is uploaded. |
| Accepted types | `acceptedTypes` | Up to 20 entries. Each is an exact MIME type (`application/pdf`), a wildcard (`image/*`), or an extension with its dot (`.ai`). A file passes if it matches any entry. An empty list accepts any type. |
| Size limit | `maxBytes` | Per-file maximum. It cannot exceed your plan's upload size limit, otherwise creating the request fails with `413` and the code `upload_size_limit`. |
| Destination bucket | `bucketId` | Where an approved file goes. Each requested file can target a different bucket. |
| Replacement target | `replacementAssetId` | Optional. When set, approving this file publishes it as a new revision of that existing file instead of creating a new one. |
Plan upload size limits are 100 MB on Free, 250 MB on Personal, 500 MB on Pro, 2 GB on Business, and 5 GB on Enterprise. The limit is checked again when the sender uploads, so a request created on a higher plan uses the current plan's limit if the workspace changes plan.
The expiration must be in the future and no more than one year away. After it passes, the link stops accepting uploads.
#### Create the request
##### In the dashboard
**Open upload forms**
Open **Requests**, switch to **Upload forms**, and choose **New upload form**.
**Describe what you need**
Enter a form name (for example "Q3 partner logos"), optional instructions, the **Requested item** label, and an **Expires** date. The link stays open until the end of that day.
**Set the rules**
Choose the destination **Bucket**, the accepted file types, and **Max size (MB)**.
**Share the link**
Choose **Create**, then **Copy link** on the new row.
The dashboard creates a request with one requested file. For several files with different rules or destinations, create the request through the API.
##### With the API
```bash title="curl"
curl -X POST https://api.steadylink.io/api/request-forms \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Northwind launch package",
"instructions": "Use the final approved exports. Vector logos only.",
"expiresAt": "2026-10-31T23:59:59Z",
"files": [
{
"label": "Primary logo",
"description": "SVG or AI, transparent background.",
"required": true,
"acceptedTypes": [".svg", ".ai"],
"maxBytes": 26214400,
"bucketId": "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c"
},
{
"label": "Hero image",
"required": true,
"acceptedTypes": ["image/*"],
"maxBytes": 104857600,
"bucketId": "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c",
"replacementAssetId": "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d"
},
{
"label": "Press release",
"required": false,
"acceptedTypes": ["application/pdf", ".docx"],
"maxBytes": 10485760,
"bucketId": "7a2d9f40-1b3c-4e5d-8f6a-0b1c2d3e4f50"
}
]
}'
```
```ts title="JavaScript"
const form = await steadylink.request<{ id: string; token: string; publicPath: string }>("/api/request-forms", {
method: "POST",
body: JSON.stringify({
name: "Northwind launch package",
instructions: "Use the final approved exports. Vector logos only.",
expiresAt: "2026-10-31T23:59:59Z",
files: [
{ label: "Primary logo", required: true, acceptedTypes: [".svg", ".ai"], maxBytes: 26_214_400, bucketId: "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c" },
{ label: "Hero image", required: true, acceptedTypes: ["image/*"], maxBytes: 104_857_600, bucketId: "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c", replacementAssetId: "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d" },
],
}),
});
const link = `https://steadylink.io${form.publicPath}`;
```
```python title="Python"
form = client.request("POST", "/api/request-forms", {
"name": "Northwind launch package",
"instructions": "Use the final approved exports. Vector logos only.",
"expiresAt": "2026-10-31T23:59:59Z",
"files": [
{"label": "Primary logo", "required": True, "acceptedTypes": [".svg", ".ai"], "maxBytes": 26214400, "bucketId": "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c"},
{"label": "Hero image", "required": True, "acceptedTypes": ["image/*"], "maxBytes": 104857600, "bucketId": "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c", "replacementAssetId": "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d"},
],
})
link = "https://steadylink.io" + form["publicPath"]
```
```json title="Response" status="201 Created"
{
"id": "e2b6c8d4-5f1a-4c7e-9a3b-6d0e1f2a3b4c",
"token": "pX3kV9qL...",
"publicPath": "/request/pX3kV9qL..."
}
```
Validation you may run into:
- `422` "Every destination must be a bucket in this workspace" when a `bucketId` is wrong.
- `422` "Every replacement target must be an asset in this workspace" when a `replacementAssetId` is wrong.
- `422` "Expiration must be within the next year".
- `409` with the code `feature_limit_reached` when the workspace already has its maximum number of open requests (see [Limits](#limits)).
Unlike replacement links, the request link can be read again later: `GET /api/request-forms` returns each form's `publicPath`.
#### What the sender sees
The link opens a page at `https://steadylink.io/request/{token}` with your request name, instructions, and one drop zone per requested file. The sender can add their name and email (both optional), uploads each file, and chooses **Send for approval**. The page checks type and size before uploading and explains a mismatch ("This file type is not accepted", or the size limit in readable units). If a required file is missing, it names the missing items.
Each file goes straight to temporary storage over a presigned URL that is valid for 15 minutes. If an upload is interrupted, starting it again for the same slot discards the partial upload. Once a file has finished uploading, its slot is filled for that submission and another upload for it returns `409` "This requested file has already been uploaded". After submitting, the sender can choose **Request more files** to send another set through the same link, which creates a separate submission.
The page tells the sender that uploads stay in temporary storage until they are approved. Nothing is added to your buckets, and no existing file changes, until you decide.
##### Public route limits
The public routes are rate-limited per IP address to protect your workspace from abuse:
| Action | Limit |
| --- | --- |
| Open the request | 120 per minute |
| Start a submission | 20 per hour |
| Start a file upload | 60 per hour |
| Confirm a file upload | 60 per hour |
| Submit | 30 per hour |
Uploads also count against the workspace's temporary intake capacity, so a sender can be refused when the workspace is out of room.
#### Review submissions
Submitted files appear in **Requests > Review queue**, newest first, with who sent them, which request and requested item they belong to, and their size. Select a file to preview it (images, audio, video, PDF, plain text, CSV, and JSON preview inline; SVG and HTML never do, for safety) or download it, then choose **Approve** or **Reject**.
In the API, `GET /api/request-forms/submissions` lists the latest 200 submissions with their files, and each file is decided with `POST /api/request-forms/files/{file_id}/decision`. There are three actions:
| `action` | What happens |
| --- | --- |
| `approve` | Saves the file. With a replacement target (from the requested file, or `replacementAssetId` in the decision), it becomes a new revision of that file and keeps its stable link. Otherwise it is added as a new file in the destination bucket, inside a `requests/` folder. |
| `reject` | Deletes the temporary upload. The file status becomes `rejected`. |
| `assign` | Changes the destination bucket without approving. A later `approve` uses the assigned bucket. |
The bucket used on approval is, in order of preference: `bucketId` in the decision, the bucket set by an earlier `assign`, then the requested file's own bucket.
```bash
# Route a file to another bucket, then approve it
curl -X POST https://api.steadylink.io/api/request-forms/files/9d4c2a71-0e3b-4f6a-8c1d-2b3e4f5a6b7c/decision \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "assign", "bucketId": "7a2d9f40-1b3c-4e5d-8f6a-0b1c2d3e4f50" }'
curl -X POST https://api.steadylink.io/api/request-forms/files/9d4c2a71-0e3b-4f6a-8c1d-2b3e4f5a6b7c/decision \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "approve", "note": "Approved for the launch campaign" }'
```
```json title="Response" status="200 OK"
{ "status": "approved", "assetId": "5b8e1f2a-3c4d-4e6f-9a0b-1c2d3e4f5a6b" }
```
Details that matter during review:
- **Names never overwrite.** A new file approved into `requests/` gets a unique name if one with the same name exists (`logo.svg` becomes `logo-2.svg`).
- **Replacements must match the bucket.** The replacement target has to live in the bucket used for approval; otherwise approval fails with `422` "Replacement asset is not in the selected bucket".
- **Decisions are final.** Approving or rejecting a file that is no longer waiting returns `409` "This file is not awaiting approval".
- **Notes are recorded.** The optional `note` (up to 2,000 characters) is stored with the decision, along with who decided and when.
#### Edit, re-link, or close a request
- **Edit** with `PUT /api/request-forms/{form_id}`, sending the full request. You can always change the name, instructions, and expiration of an open request. The requested files themselves can only change until the first submission arrives; after that, changing them returns `409` "File requirements cannot change after responses begin". Include each existing requested file's `id` to keep it.
- **Generate a new link** with **Generate new link** in the dashboard or `POST /api/request-forms/{form_id}/rotate`. The old link stops working immediately.
- **Revoke** with **Revoke** in the dashboard or `DELETE /api/request-forms/{form_id}`. The link returns `410 Gone` with "This request link was revoked". Files already received stay in the review queue.
Only open requests (not revoked, not expired) can be edited or given a new link.
#### Expiry
An expired link returns `410 Gone` with "This request link has expired" and accepts nothing. SteadyLink then cleans up: temporary files from that request that were never decided are deleted and marked `expired`, so review anything you need before the expiration. Members who have the `link.expiring` notification turned on are told when an open request has less than 24 hours left (see [Notifications](https://steadylink.io/docs/guides/notifications)).
#### Limits
| Plan | Open requests at once |
| --- | --- |
| Free | 2 |
| Personal | 10 |
| Pro | 50 |
| Business | 250 |
| Enterprise | 2,500 |
Only requests that are neither revoked nor expired count. Revoking one frees a slot immediately.
Creating, editing, and deciding requires a workspace role that can edit files (member, admin, or owner) or an API key with `assets:write`. Listing requests and submissions, previewing, and downloading need `assets:read`.
#### Next steps
- [Requests API](https://steadylink.io/docs/reference/api/requests): Every request-form route, including the public submission flow.
- [Notifications](https://steadylink.io/docs/guides/notifications): Get an alert when a submission arrives.
- [Buckets and folders](https://steadylink.io/docs/guides/buckets-and-folders): Choose destinations that keep approved files organized.
### Upload widget
Source: https://steadylink.io/docs/guides/upload-widget
The upload widget is a drop-in upload box for your website. Use it when people outside your workspace need to send you files, such as artwork from customers, documents from applicants, or an updated logo from a partner, and you do not want to build an upload backend. You configure it in the dashboard, paste two lines of HTML into your site, and uploads land in a bucket you choose or replace one specific file. By default nothing goes live until someone in your workspace approves it.
#### Create a widget
1. Open **Share > Upload widgets** and choose **New widget**.
2. Give it a **Name** your team will recognize in the review queue, such as `Website contact form`.
3. Under **Uploads go to**, choose:
- **New files in a bucket** to collect new files, then pick the **Bucket**.
- **Replace one file** to let visitors send a new version of one existing file, then pick the **File to replace**. Approving an upload publishes it as the next revision behind that file's existing link.
4. Choose the accepted file types and the **Max size (MB)**.
5. List the **Websites allowed to embed**, one per line, for example `https://example.com`.
6. Leave **Review each upload before it goes live** on unless you fully trust everyone who can reach the page.
7. Save, then choose **Copy embed code** on the widget's card.
The number of active widgets depends on your plan (one on Free). A paused widget does not count; use **Pause widget** to stop accepting uploads without deleting the configuration.
#### Embed it
Paste the embed code where the upload box should appear. It looks like this:
```html title="HTML"
```
The script finds every element with `data-steadylink-widget` when the page loads and renders an upload box inside it (in a shadow root, so your site's CSS does not leak in). The box has a button, a drop zone, a progress bar, and a status line announced to screen readers.
**Embed attributes**
- `data-steadylink-widget` (string): The widget's public key, starting with `wdg_`. It identifies the configuration and is safe to publish. It is not a workspace credential and grants nothing on its own.
- `data-api` (URL): The SteadyLink API origin, `https://api.steadylink.io`. Without it the element stays empty.
- `data-button-text` (string, default Choose a file): Label of the upload button.
- `data-accept` (string): Comma-separated MIME types, wildcards such as `image/*`, or extensions such as `.pdf`. Filters the file picker and gives an instant error for other types.
- `data-max-bytes` (integer): Largest file in bytes. Larger files get an instant error instead of a failed upload.
- `data-theme` (string, default light): `dark` renders the dark style. Any other value renders the light style.
- `data-callback` (string): Name of a global function to call with the result when an upload completes, for example `onArtworkUploaded`.
`data-accept` and `data-max-bytes` only improve the visitor's experience. The rules that matter are the ones saved on the widget, and SteadyLink enforces them on its side for every upload. Editing the attributes in a browser does not bypass them.
##### Pages that render later
If your page adds the element after load (a modal, a client-side route, a React component), mount it yourself once the script has loaded:
```js
window.SteadyLinkUpload.mount(document.querySelector("#artwork-upload"));
// or re-scan the whole page
window.SteadyLinkUpload.scan();
```
Mounting the same element twice is ignored, so calling `scan()` after every route change is safe.
#### React to completed uploads
When an upload finishes, the element dispatches a `steadylink:complete` event that bubbles, and calls the `data-callback` function if you set one. The event's `detail` is the completion result:
```js
document.querySelector("[data-steadylink-widget]").addEventListener("steadylink:complete", (event) => {
const { status, assetId } = event.detail;
if (status === "awaiting_approval") {
showMessage("Thanks. We will review your file shortly.");
} else {
showMessage(`Received. File ID ${assetId}`);
}
});
document.querySelector("[data-steadylink-widget]").addEventListener("steadylink:error", (event) => {
console.warn("Upload failed:", event.detail.message);
});
```
**steadylink:complete detail**
- `status` (string): `awaiting_approval` when the widget reviews uploads, or `published` when the file went live immediately.
- `assetId` (string | null): The new or replaced file's asset ID when `status` is `published`. `null` while the upload waits for review.
- `replacementRequestId` (string | null): Reserved. Currently always `null`.
The widget also updates its own status line ("Submitted for review" or "Upload complete"). `steadylink:error` fires when validation, the transfer, or completion fails, with `detail.message` describing the failure.
#### Security and upload flow
The widget never sees a workspace API key or storage credentials. Each upload works like this:
1. The browser asks `POST /api/public/widgets/{public_key}/initialize` for a session, sending the file's name, size, and type. SteadyLink checks that the request's `Origin` is on the widget's list, that the widget is active, and that the file fits the widget's type and size rules and your plan's limits.
2. It answers with a ten-minute, origin-bound session with one create or replace permission: a one-time session token and a presigned upload URL that both expire after ten minutes.
3. The browser PUTs the bytes directly to that URL with `Content-Type: application/octet-stream`.
4. The browser calls `POST /api/public/widgets/{public_key}/complete` with the session token as `Authorization: Bearer`. The request must come from the same origin that started the session, and the stored file must be exactly the declared size.
5. The session is consumed at that moment and cannot be replayed. The upload either waits for review or is published.
Origins are matched on scheme, host, and port only. `https://example.com`, `https://www.example.com`, and `http://example.com` are three different origins, so list every variant your site is served from. A widget accepts up to 30 origins. Paths are not allowed in an origin, and pages on any path of a listed origin can use the widget.
Because the public key is visible in your page's HTML, anyone can copy the embed code. The origin check stops other websites from using it in a browser, but it cannot stop a determined person sending requests from a script with a forged `Origin` header. That is the reason review is on by default.
#### Review uploads before they go live
With **Review each upload before it goes live** on, completed uploads stay in temporary storage. They do not appear in your buckets and do not change any live file until a workspace member approves them. Your workspace is notified with "Widget upload needs approval".
Open **Share** and go to the **Review queue** to preview each upload, download it, and choose **Approve** or **Reject**:
- **Approve** on a **New files** widget adds the file to the widget's bucket, in a folder called `widget-submissions/`, where it is finalized and scanned like any upload. On a **Replace one file** widget it publishes the upload as the next revision of the target file, so the file's existing link starts serving it.
- **Reject** deletes the temporary file. This cannot be undone.
Uploads waiting for review expire after seven days and can no longer be approved. Deleting a widget also deletes any uploads still waiting for review. Approvals and rejections are also available through the [Sharing API](https://steadylink.io/docs/reference/api/sharing).
##### Publishing without review
Turning review off publishes each upload as soon as it completes: new files go to the root of the widget's bucket, and replacements go live behind the target file's link. The dashboard warns you that "Files sent through this widget will go live as soon as they finish uploading."
**Warning.**
With review off, an upload whose name matches a file that already exists at the root of the destination bucket becomes a new revision of that file. Point an unreviewed widget at a dedicated bucket that holds nothing else, and use review for any widget on a public page.
Uploaded files are scanned for malware either way, and a file that fails the scan is never served.
#### Limits
| Limit | Value |
| --- | --- |
| Session and upload URL | Ten minutes from initialization. |
| File size | The widget's **Max size**, which cannot exceed your plan's maximum upload size or 2 GB. |
| Accepted types | Up to 30 entries: MIME types, wildcards such as `image/*`, or extensions such as `.pdf`. An empty list accepts any type. |
| Allowed origins | 1 to 30. |
| Files per upload | One. Visitors can upload again after each file finishes. |
| Review window | Seven days. |
#### Troubleshooting
| What the visitor sees | Cause |
| --- | --- |
| Nothing renders | The script did not load, `data-api` is missing, or the element was added after load without calling `mount()`. |
| "The upload could not be initialized." | The page's origin is not on the widget's list, the widget is paused or deleted, or the file is too large or the wrong type for the widget's saved rules. |
| "The file could not be uploaded." | The transfer to storage failed or took longer than ten minutes. Try again. |
| "The upload could not be completed." | The session expired, was already used, or the file did not pass validation. |
Open **Share** to check the widget's origins and rules, then load your page from exactly one of the listed origins. You can try a widget on the [example page](https://steadylink.io/widget-example?widget=wdg_your_public_key) once its origin list includes `https://steadylink.io`.
#### Next steps
- [Browser uploads](https://steadylink.io/docs/guides/browser-uploads): Build your own upload flow when you need full control over the UI and destination.
- [Replacement requests](https://steadylink.io/docs/guides/replacement-requests): Ask one person for a new version of one file through an expiring link.
- [Sharing API](https://steadylink.io/docs/reference/api/sharing): Manage widgets and review submissions programmatically.
### Browser uploads
Source: https://steadylink.io/docs/guides/browser-uploads
This guide is for developers adding an upload button to their own web app: profile photos, attachments, customer documents. The browser sends the file bytes directly to SteadyLink's storage, so large files never pass through your server, and your SteadyLink API key never leaves your backend. You end up with two small server routes and a few lines of browser code. If you do not want to write any code, the [upload widget](https://steadylink.io/docs/guides/upload-widget) is a ready-made embed for the same job.
#### How the flow works
A browser upload is the normal [three-part upload](https://steadylink.io/docs/guides/upload-and-replace#how-an-upload-works), split between your server and the browser:
1. **Your server creates an upload session.** It authenticates your user, decides where the file goes, and calls `POST /api/upload-batches` with your API key. SteadyLink returns a session ID and a presigned `uploadUrl`.
2. **The browser PUTs the bytes to `uploadUrl`.** This request goes to object storage, carries no SteadyLink credentials, and can report progress.
3. **Your server completes the session.** It calls `POST /api/upload-sessions/{id}/complete`, SteadyLink finalizes and scans the file, and your server gets the asset ID and link.
Two rules make this safe and are not optional:
- **The API key stays on the server.** Anyone who sees a key can use all of its scopes. The SteadyLink API also only accepts cross-origin browser requests from SteadyLink's own apps, so a browser call with a key from your site would fail anyway.
- **The `uploadUrl` is the only thing the browser needs.** It allows one PUT of one file to one temporary location for 15 minutes. It cannot read, list, or overwrite anything else.
#### Step 1: create the session on your server
This Next.js route handler authenticates the user, validates the file, and creates a one-file batch in a per-user folder. It uses the TypeScript SDK; the same request with curl is in the [Uploads API](https://steadylink.io/docs/reference/api/uploads).
```ts title="app/api/uploads/route.ts"
import { SteadyLink } from "@steadylink/sdk";
import { getCurrentUser } from "@/lib/auth"; // your own auth
const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! });
const BUCKET_ID = process.env.STEADYLINK_UPLOADS_BUCKET_ID!;
const MAX_BYTES = 25 * 1024 * 1024;
const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]);
export async function POST(request: Request) {
const user = await getCurrentUser();
if (!user) return Response.json({ error: "Sign in to upload" }, { status: 401 });
const { filename, size, contentType } = await request.json();
if (typeof filename !== "string" || !Number.isInteger(size) || size <= 0 || size > MAX_BYTES) {
return Response.json({ error: "File must be smaller than 25 MB" }, { status: 400 });
}
if (!ALLOWED.has(contentType)) {
return Response.json({ error: "Upload a JPEG, PNG, WebP, or PDF" }, { status: 415 });
}
// One folder per user. The folder is checked again before completing.
const batch = await steadylink.createUploadBatch(BUCKET_ID, [
{ filename, size, contentType, path: `users/${user.id}/` },
]);
const session = batch.files[0]!;
return Response.json({
batchId: batch.id,
sessionId: session.id,
uploadUrl: session.uploadUrl,
});
}
```
Things to decide here rather than in the browser:
- **Where the file goes.** Never take a bucket ID or folder from the request body. Here the folder is derived from the signed-in user.
- **Names.** A file uploaded to a key that already exists becomes a new revision of that file instead of a copy. If two uploads from the same user may share a name, prefix the filename with something unique, such as `${crypto.randomUUID()}-${filename}`.
- **Size and type.** Your checks give users fast, friendly errors. SteadyLink enforces its own limits as well: your plan's maximum file size (and a lower API-key ceiling on some plans), any limits set on the bucket, a content check that rejects files whose bytes do not match their type, and a malware scan. Setting a **Maximum upload size** on the destination bucket gives you a server-side backstop.
#### Step 2: send the bytes from the browser
The browser PUTs the `File` object as the request body. Two details matter:
- The `Content-Type` header must be exactly `application/octet-stream`, whatever the file type. The URL is signed for that value, and storage rejects anything else with `403`. The real type was declared in step 1 and is detected again from the bytes.
- Send no other headers and no credentials. In particular, do not add your session cookie or an `Authorization` header.
`fetch` is enough when you do not need progress. For a progress bar, use `XMLHttpRequest`, whose `upload.onprogress` event reports bytes sent (`fetch` cannot report upload progress in most browsers).
```tsx title="components/upload-button.tsx"
"use client";
import { useState } from "react";
function putWithProgress(url: string, file: File, onProgress: (percent: number) => void) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
};
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`Storage returned ${xhr.status}`)));
xhr.onerror = () => reject(new Error("The upload lost its network connection"));
xhr.send(file);
});
}
export function UploadButton() {
const [status, setStatus] = useState("");
const [link, setLink] = useState(null);
async function upload(file: File) {
setLink(null);
setStatus("Preparing upload");
const created = await fetch("/api/uploads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, size: file.size, contentType: file.type }),
});
if (!created.ok) return setStatus((await created.json()).error ?? "Upload refused");
const { batchId, sessionId, uploadUrl } = await created.json();
try {
await putWithProgress(uploadUrl, file, (percent) => setStatus(`Uploading ${percent}%`));
} catch (error) {
return setStatus(error instanceof Error ? error.message : "Upload failed");
}
setStatus("Checking the file");
const completed = await fetch(`/api/uploads/${sessionId}/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ batchId }),
});
const result = await completed.json();
if (!completed.ok) return setStatus(result.error ?? "Upload failed");
setStatus("Done");
setLink(result.url);
}
return (
event.target.files?.[0] && void upload(event.target.files[0])} />
{status}
{link &&
{link} }
);
}
```
The same request without progress:
```ts
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "application/octet-stream" },
body: file,
});
```
#### Step 3: complete the upload on your server
The completion route checks that the session belongs to the signed-in user, completes it, waits for finalization and the malware scan, and returns the link.
```ts title="app/api/uploads/[sessionId]/complete/route.ts"
import { SteadyLink, SteadyLinkError, SteadyLinkTimeoutError } from "@steadylink/sdk";
import { getCurrentUser } from "@/lib/auth";
const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! });
export async function POST(request: Request, { params }: { params: Promise<{ sessionId: string }> }) {
const user = await getCurrentUser();
if (!user) return Response.json({ error: "Sign in to upload" }, { status: 401 });
const { sessionId } = await params;
const { batchId } = await request.json();
// Only complete sessions this user created: their folder is part of the session.
const batch = await steadylink.getUploadBatch(batchId);
const session = batch.files.find((file) => file.id === sessionId);
if (!session || session.path !== `users/${user.id}/`) {
return Response.json({ error: "Upload not found" }, { status: 404 });
}
try {
await steadylink.completeUpload(sessionId); // sends Idempotency-Key: complete-{sessionId}
const finished = await steadylink.waitForUploads(batchId, [sessionId], { timeoutMs: 60_000 });
const file = finished.files.find((item) => item.id === sessionId)!;
if (file.status !== "ready") {
return Response.json({ error: file.error?.message ?? `Upload ${file.status}` }, { status: 422 });
}
return Response.json({ assetId: file.objectAssetId, url: steadylink.link(file.objectAssetId!) });
} catch (error) {
if (error instanceof SteadyLinkTimeoutError) {
return Response.json({ error: "Still processing. Check again shortly." }, { status: 202 });
}
if (error instanceof SteadyLinkError) {
return Response.json({ error: error.message }, { status: error.status === 410 ? 410 : 502 });
}
throw error;
}
}
```
Notes on this step:
- **Completion is idempotent.** If the browser retries, `completeUpload` sends the same `Idempotency-Key`, and a session that already finished returns its final state. That is why it is safe to call from a route the user can hit twice.
- **Waiting is optional.** You do not have to hold the request open while SteadyLink finalizes and scans the file. You can return right after `completeUpload` and let the browser poll a status route that calls `getUploadBatch`, or listen for the `upload.updated` server-sent events on `GET /api/events/stream` from your server.
- **Visibility.** New files take the destination bucket's default. For user content that should not be public, keep the bucket private and hand out [signed links](https://steadylink.io/docs/security/private-links). To make one file public, call `steadylink.setVisibility(BUCKET_ID, session.path + session.filename, "public")` after it is `ready`.
- **Size mismatches.** If the bytes the browser sent do not match the declared `size`, the session ends `failed` with `upload_size_mismatch`. This also catches a user picking a different file between steps.
#### Several files at once
One batch holds up to 100 sessions, each with its own `uploadUrl`. Send all the file descriptions in step 1, PUT each file to its own URL (two to four at a time is a good balance), and complete each session separately. A file that fails does not affect the others; the batch status ends `partial` if any file was blocked or failed.
#### When storage is unreachable
Some corporate networks block the storage host that presigned URLs point to while allowing your own domain. For those users you can route the bytes through your server, which forwards them to SteadyLink's API instead of storage:
```ts title="app/api/uploads/[sessionId]/content/route.ts"
import { SteadyLink } from "@steadylink/sdk";
const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! });
export async function PUT(request: Request, { params }: { params: Promise<{ sessionId: string }> }) {
const { sessionId } = await params;
const batchId = new URL(request.url).searchParams.get("batch")!;
const batch = await steadylink.getUploadBatch(batchId);
const session = batch.files.find((file) => file.id === sessionId);
if (!session) return new Response("Not found", { status: 404 });
// Check ownership here exactly as in the completion route.
await steadylink.sendUploadBytes(session, request.body!, { via: "api" });
return new Response(null, { status: 204 });
}
```
`via: "api"` streams the bytes to `PUT /api/upload-sessions/{session_id}/content` with your API key, so it must run on your server. That route requires the body to be exactly the declared size, accepts bytes until the session's `expiresAt` (not just the 15-minute URL lifetime), and leaves the session `uploaded`, ready to complete as usual. The trade-off is that every byte now passes through your server, so your hosting platform's request size and duration limits apply. Use it as a fallback, not the default. The SDK's `upload()` and the CLI accept the same `via: "api"` option (`--via api`) for server-side scripts on restricted networks.
#### Troubleshooting
| Symptom | Cause |
| --- | --- |
| `403` from storage on the PUT | The `Content-Type` was not `application/octet-stream`, extra signed headers were added, or the URL is older than 15 minutes. Create a new session. |
| Session `failed` with `upload_size_mismatch` | The bytes sent differ from the declared `size`. |
| Session `failed` with `upload_missing` | Completion was called before the PUT finished, or the PUT never reached storage. |
| `410` from completion | The session expired. Create a new one. |
| `409 Idempotency key mismatch` | Completion was retried with a different `Idempotency-Key`. Reuse the first one. |
| `413` with `upload_size_limit` when creating the session | The file is larger than your plan or API key allows. |
| Session `blocked` | The file failed the malware scan or a bucket rule. Do not retry it. |
| `429` | Too many requests for your plan's rate limit. Retry after the `Retry-After` header. |
#### Next steps
- [Upload widget](https://steadylink.io/docs/guides/upload-widget): A no-code embed that does this whole flow for you, with review before publishing.
- [Uploads API](https://steadylink.io/docs/reference/api/uploads): Request and response details for every upload route.
- [TypeScript SDK](https://steadylink.io/docs/reference/sdk): `createUploadBatch`, `completeUpload`, `waitForUploads`, and `sendUploadBytes`.
### Website migration
Source: https://steadylink.io/docs/guides/website-migration
Website migration is for moving an existing site's media into SteadyLink without downloading and re-uploading it by hand. You give SteadyLink a starting URL; it crawls up to 200 pages on that site, lists every image, video, document, and download those pages reference, and checks whether each one still loads. You pick the files to keep, and an import job copies them into a bucket folder. The result is a CSV or JSON map from each original URL to its new stable link, which you use to update your site's source.
#### Before you start
- The site must be reachable on the public internet over HTTP or HTTPS on the standard ports (80 or 443). Addresses that resolve to private, loopback, link-local, multicast, or reserved ranges, `localhost`, and cloud metadata hosts are refused, including through redirects. A URL with a username or password in it is refused too.
- The crawler identifies itself as `SteadyLink-Migration-Scanner/1.0` and follows the site's `robots.txt`. Pages disallowed for that user agent are skipped. If your staging site blocks crawlers, allow this user agent for the duration of the scan.
- Decide which bucket and folder the files should land in. You can type a new folder path; it is created during the import.
#### Run a scan
**Start the scan**
Open **Migrate a site**, enter a domain or starting URL such as `northwind.example` or `https://northwind.example/products`, and start the scan. A bare domain is treated as `https://`. The dashboard scans up to 50 pages; use the API to scan up to 200.
**Wait for the report**
The scan runs in the background. Progress, pages scanned, and media found update as it goes. You can leave the page and come back through **History**.
**Review the report**
When the status is `complete`, the report lists every media URL found, with its type, size, status, and how many pages reference it.
```bash title="Start a scan with the API"
curl -X POST https://api.steadylink.io/api/migration-scans \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://northwind.example", "maxPages": 200 }'
```
```json title="Response" status="202 Accepted"
{
"id": "a3c5e7f9-1b2d-4e6f-8a0c-2e4f6a8b0c1d",
"url": "https://northwind.example",
"status": "queued",
"maxPages": 200,
"pagesScanned": 0,
"mediaFound": 0,
"progress": 0,
"error": null,
"createdAt": "2026-10-08T15:20:44.019311",
"updatedAt": "2026-10-08T15:20:44.019311"
}
```
Poll `GET /api/migration-scans/{scan_id}` until `status` is `complete` or `failed`. `maxPages` accepts 1 to 200 and defaults to 50.
##### How the crawler explores
The crawl starts at your URL and follows links breadth-first, staying on the exact host you started on (`www.northwind.example` and `northwind.example` are different hosts). It stops when it has visited `maxPages` pages or runs out of links. Each page can be up to 3 MB; larger pages and pages that fail to load are skipped and do not stop the scan. Only HTML responses are read for references; other content types are skipped.
Media can live on any public host, such as a CDN or an old asset domain. The crawler records up to 5,000 distinct media URLs per scan.
##### What counts as media
| Reference | Recorded as |
| --- | --- |
| ` `, ``, ``, `` `src`, `poster`, and every `srcset` candidate | `img`, `source`, `video`, `audio` |
| ` ` and ` ` | `link` |
| `og:image`, `og:video`, `og:audio`, and `twitter:image` meta tags | `open_graph` |
| `url(...)` in an element's inline `style` attribute | `css_background` |
| Links to files ending in a media or document extension, or links with a `download` attribute | `download` |
Download extensions are `.avif`, `.bmp`, `.csv`, `.doc`, `.docx`, `.gif`, `.ico`, `.jpeg`, `.jpg`, `.mp3`, `.mp4`, `.pdf`, `.png`, `.ppt`, `.pptx`, `.svg`, `.tif`, `.tiff`, `.webm`, `.webp`, `.xls`, `.xlsx`, and `.zip`. URLs starting with `data:`, `javascript:`, `mailto:`, or `tel:` are ignored. Background images declared in external stylesheets are not followed.
#### Read the report
Each media entry in the report has:
| Field | Meaning |
| --- | --- |
| `originalUrl` | The absolute URL as referenced, without its fragment. |
| `mediaType` | Where it was found, from the table above. |
| `contentType`, `byteSize` | What the server reported when probed. The scan does not download whole files, so this comes from response headers. |
| `statusCode` | The HTTP status of the probe. |
| `broken` | `true` when the URL failed to load or returned 400 or higher. |
| `pageUrls`, `references` | The pages that reference it, and how many. The report lists the most referenced files first. |
| `importedAssetId`, `newUrl` | Filled in once the file is imported. |
A broken entry is worth fixing on your current site whether or not you migrate: it is a reference that already fails for your visitors.
#### Import the files you want
Select files in the report, choose a **Destination bucket** and **Folder** (default `website-import`), and choose **Import _n_ selected**. Through the API:
```bash
curl -X POST https://api.steadylink.io/api/migration-scans/a3c5e7f9-1b2d-4e6f-8a0c-2e4f6a8b0c1d/imports \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mediaIds": ["4d6f8a0c-2e4f-4a8b-9c1d-3e5f7a9b1c2d", "6f8a0c2e-4f6a-4b0c-8d2e-5f7a9b1c3d4e"],
"bucketId": "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c",
"path": "website-import/images"
}'
```
```json title="Response" status="202 Accepted"
{ "queued": true }
```
One import can include up to 200 files. Every selected file must belong to this scan, must not be marked broken, and must not already be imported; otherwise the request fails with `422` "Scan, bucket, or selected media is invalid". The scan itself must be `complete`. The folder path can be nested; `.` and `..` segments and control characters are rejected.
While the import runs, the scan's status is `importing`. For each file, SteadyLink:
1. Downloads it from the original URL, up to your plan's upload size limit.
2. Detects the real type from the bytes. A file whose bytes do not match the type its server declared (for example an HTML error page served as `image/png`) is skipped and marked broken instead of being imported.
3. Scans it for malware and saves it into the folder. File names come from the URL path; if a name is already taken in that folder, a suffix is added (`hero.jpg`, `hero-2.jpg`) so nothing is overwritten.
4. Records the new asset and its stable link, `https://cdn.steadylink.io/a/{asset_id}`, on the report entry.
Files that fail any step are marked broken and the import continues with the rest. When it finishes, the scan returns to `complete`. If a later scan finds a URL you already imported into the same bucket folder, importing it again reuses that file instead of creating a duplicate.
#### Export the URL map
Once files are imported, download the map and use it to update references in your site.
- In the dashboard, choose **Export map** on the report to download a CSV.
- Through the API, call `GET /api/migration-scans/{scan_id}/export/csv` or `/export/json`. Only imported files are included.
```text title="steadylink-mapping-a3c5e7f9-1b2d-4e6f-8a0c-2e4f6a8b0c1d.csv"
originalUrl,steadyLinkUrl
https://northwind.example/wp-content/uploads/2024/03/hero.jpg,https://cdn.steadylink.io/a/5b8e1f2a-3c4d-4e6f-9a0b-1c2d3e4f5a6b
https://cdn.oldhost.example/brochure.pdf,https://cdn.steadylink.io/a/9c0d1e2f-3a4b-4c5d-8e6f-7a8b9c0d1e2f
```
The JSON export contains the same `mapping` array plus `instructions` and short `examples` for HTML, Next.js, and CSS. Search for each original URL in your source, replace it, review the diff, and deploy as you normally would. Remember that `srcset` attributes and CSS files may reference the same image several times.
Once your site points at SteadyLink links, you can update any of those files later by [replacing it](https://steadylink.io/docs/guides/upload-and-replace); the links in your site never need to change again.
#### Cancel or retry
- **Cancel** with `POST /api/migration-scans/{scan_id}/cancel` while a scan is `queued`, `running`, or `importing`. Work stops at the next page or file. Files already imported stay in your bucket.
- **Retry** a scan whose crawl failed with `POST /api/migration-scans/{scan_id}/retry`. It starts the crawl again from the beginning.
- **Resume a failed import** by sending the import request again for the files that are not yet imported. If an import fails partway, the scan's `error` starts with "Import failed:" and the import route accepts the scan even though it is not `complete`.
#### Limits
| Limit | Value |
| --- | --- |
| Pages per scan | 1 to 200 |
| Files per import request | 200 |
| Media URLs recorded per scan | 5,000 |
| Scans started per workspace | 20 in any 24 hours, then `429` |
| Scanned pages per billing period | Free 100, Personal 1,000, Pro 10,000, Business 100,000, Enterprise 1,000,000 |
Pages count against the monthly allowance when the scan starts: a queued or running scan reserves its full `maxPages`, and a finished scan counts the pages it actually visited. Asking for more pages than remain returns `409` with the code `feature_limit_reached`, so lower `maxPages` or wait for the next billing period.
Starting scans and imports requires a workspace role that can edit files (member, admin, or owner) or an API key with `assets:write`. Reading reports and exports needs `assets:read`.
#### Next steps
- [Requests API](https://steadylink.io/docs/reference/api/requests): Exact routes and fields for scans, imports, and exports.
- [Stable links](https://steadylink.io/docs/concepts/stable-links): Why the links in your URL map keep working after you replace files.
- [Images in Next.js](https://steadylink.io/docs/guides/nextjs-images): Serve migrated images through `next/image`.
### File conversions
Source: https://steadylink.io/docs/guides/file-conversions
SteadyLink converts files between formats as temporary jobs: you upload a source (or point at a file already in your workspace), a worker converts it, and you download the result before it expires. Nothing is added to your storage unless you deliberately save a result into a bucket. This guide covers what can be converted, how the limits and credits work, and how to run conversions from the dashboard, the public converter, or the API.
#### Where to convert
- **Dashboard > Convert**: drop several files, pick an output format per group or per file, and download results. Jobs use your workspace's plan limits and credits.
- **steadylink.io/convert**: the public converter. It works without an account (guest limits apply) and offers **Save to workspace** when you are signed in. A guest who signs up can still save a result they converted before signing in, as long as it has not expired.
- **The API**: create jobs with an API key or a signed-in session. Useful for converting files already stored in SteadyLink without downloading them first.
#### Supported formats
A conversion is supported when the source format is an input of a group and the target is an output of the same group.
| Group | Inputs | Outputs |
| --- | --- | --- |
| Images | JPG, JPEG, PNG, WebP, AVIF, GIF, TIFF, BMP, HEIC, ICO, SVG | JPG, PNG, WebP, AVIF, GIF, TIFF, BMP, ICO |
| Documents | DOC, DOCX, ODT, RTF, TXT, HTML | PDF, DOCX, ODT, RTF, TXT, HTML |
| Spreadsheets | XLS, XLSX, ODS, CSV | PDF, XLSX, ODS, CSV |
| Presentations | PPT, PPTX, ODP | PDF, PPTX, ODP |
| PDF | PDF | JPG, PNG, TXT, DOCX, PDF |
| Audio | MP3, WAV, AAC, FLAC, OGG, M4A, OPUS | MP3, WAV, AAC, FLAC, OGG, M4A, OPUS |
| Video | MP4, WebM, MOV, AVI, MKV | MP4, WebM, MOV, AVI, MKV, GIF, MP3 |
| Archives | ZIP, TAR, TAR.GZ, 7Z | ZIP, TAR, TAR.GZ, 7Z |
| Ebooks | EPUB, MOBI, AZW3, PDF, TXT | EPUB, MOBI, AZW3, PDF, TXT |
Format names are case-insensitive, a leading dot is ignored, and `jpeg`, `tif`, and `tgz` are treated as `jpg`, `tiff`, and `tar.gz`. A PDF source always uses the PDF group, so PDF to EPUB is not available. An unsupported pair is rejected with `422` and a message such as "Conversion from PNG to PDF is not supported". `GET /api/conversions/catalog` returns this table along with the limits for every plan, and needs no authentication.
Note that SVG and HEIC are inputs only: you can convert from them but not to them.
##### Options
Options are optional and only affect the formats they apply to.
| Option | Values | Effect |
| --- | --- | --- |
| `width`, `height` | 1 to 16,384 px | Resize while keeping the aspect ratio. Give one to scale proportionally. Images are only ever scaled down. |
| `quality` | 1 to 100, default 85 | Encoder quality for JPG, WebP, and AVIF output. Out-of-range values are clamped. |
| `effort` | `fast`, `balanced` (default), `maximum` | Encoding effort for WebP and AVIF. More effort gives smaller files and takes longer. |
| `background` | Up to 32 characters, default white | Fill color for transparent areas when the output (JPG, BMP) has no transparency. |
| `fps` | 1 to 30, default 12 | Frame rate of GIFs made from video. GIFs default to 720 px wide. |
Any other option name is rejected with `422` "Unsupported conversion option". Converted images have their metadata stripped, and photos are rotated according to their EXIF orientation first. Video and audio outputs also drop source metadata.
#### Limits by plan
Guest jobs on the public converter, without an account:
| Limit | Value |
| --- | --- |
| Source size | 25 MB, or 15 MB for video |
| Jobs | 10 per rolling 24 hours |
| Running at once | 2 |
| Result retention | 1 hour |
Signed-in and API-key jobs use the workspace's plan (with an active subscription; otherwise Free limits):
| Plan | Max source | Max video source | Running at once | Video running at once | Monthly credits | Monthly video credits | Max video length | Max video output | Result retention |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Free | 100 MB | 50 MB | 2 | 1 | 250 | 50 | 5 min | 1920 x 1080 | 24 hours |
| Personal | 250 MB | 100 MB | 2 | 1 | 2,500 | 500 | 15 min | 1920 x 1080 | 24 hours |
| Pro | 500 MB | 250 MB | 4 | 2 | 25,000 | 5,000 | 1 hour | 3840 x 2160 | 48 hours |
| Business | 1 GB | 500 MB | 6 | 4 | 250,000 | 50,000 | 3 hours | 3840 x 2160 | 72 hours |
| Enterprise | 2 GB | 1 GB | 8 | 8 | 2,500,000 | 500,000 | 8 hours | 7680 x 4320 | 7 days |
"Running at once" counts jobs that are created, uploaded, queued, or processing, across the whole workspace. A video longer than the plan allows fails with `video_duration_limit`. A video output larger than the plan's maximum is not rejected: it is scaled down to fit, keeping the aspect ratio, and the job's `stageDetail` says so, for example "Adjusted to 1920x1080 to fit the Free plan". Enterprise contracts can raise any of these values.
When a limit is hit at creation time:
| Status | Code | Cause |
| --- | --- | --- |
| `413` | `conversion_size_limit`, `video_size_limit` | Source larger than the plan allows. |
| `429` | `conversion_concurrency_limit`, `video_concurrency_limit` | Too many jobs running. Wait for one to finish. |
| `429` | `conversion_daily_limit` | Guest daily job limit reached. |
| `429` | `usage_limit_exceeded` | Not enough monthly credits left for the job's estimate. The response includes `resetAt`. |
#### How credits are calculated
Credits measure the work a job causes. Regular conversions and video conversions draw from separate monthly allowances. Guest jobs do not use credits.
Before a job runs, SteadyLink reserves a conservative estimate, so a job only starts if the workspace can afford it. For audio and video the worker measures the real duration and resolution after downloading and reserves the difference if the estimate was low. When the job finishes, the reservation is replaced by the measured cost. Cancelled jobs, malware blocks, missing sources, and worker failures refund their reservation.
The measured cost of a finished job is:
```text
credits = max(1, base estimate + output blocks + compute seconds x compute weight)
```
- **output blocks**: the result size divided by 10 MB, rounded up.
- **compute seconds**: total job time from download to upload, rounded up to a whole second.
- **compute weight**: 4 for video, 2 for audio, 1 for everything else.
The base estimate depends on the group. "Input blocks" is the source size divided by 5 MB, rounded up.
| Group | Base estimate |
| --- | --- |
| Images | 1 + input blocks + requested output megapixels / 8, rounded up (at least 1; it is 1 when you do not set both `width` and `height`) |
| Documents | 8 + 2 per page + 2 per input block |
| PDF | 10 + 2 per page + 2 per input block |
| Spreadsheets | 14 + 3 per page + 2 per input block |
| Presentations | 16 + 3 per page + 2 per input block |
| Ebooks | 12 + 2 per page + 2 per input block |
| Archives | 12 + 2 per file inside the archive + 3 per input block |
| Audio | 30 + 5 per started 15 seconds + 2 per input block |
| Video | 100 + 12 per started 5 seconds x resolution factor + 4 per input block |
Pages are counted for sources in the PDF group; every other document counts as one page. The video resolution factor is the output's pixel count divided by 1280 x 720, rounded up, and never less than 3 (a 1080p output, or anything smaller); a 4K output has a factor of 9.
Two worked examples:
- A 3 MB PNG converted to WebP at 1,200 px wide in under a second: 1 + 1 input block + 1 for pixels = 3, plus 1 output block and 1 compute second, for **5 credits**.
- A 40-second, 60 MB 1080p MOV converted to MP4 in 25 seconds, producing 30 MB: 100 + (8 blocks x 12 x 3) + (12 input blocks x 4) = 436, plus 3 output blocks and 25 x 4 compute, for **539 video credits**.
**Note.**
`GET /api/conversions/catalog` also returns a `weight` for each group. It indicates relative cost only; the charge is always the formula above.
#### Convert with the API
##### Convert a file already in SteadyLink
Pass `sourceAssetId` and the job is queued immediately; there is nothing to upload. The source must have passed malware scanning (`409` otherwise), must not be encrypted at rest (`409` "Encrypted stored files cannot be converted in place yet"), and `inputFormat` must match the stored file's extension. Add `sourceVersion` to convert an older revision instead of the current one.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/conversions \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sourceAssetId": "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d",
"inputFormat": "png",
"outputFormat": "avif",
"options": { "width": 1200, "quality": 70 }
}'
```
```ts title="JavaScript"
const job = await steadylink.request<{ id: string; status: string }>("/api/conversions", {
method: "POST",
body: JSON.stringify({
sourceAssetId: "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d",
inputFormat: "png",
outputFormat: "avif",
options: { width: 1200, quality: 70 },
}),
});
```
```python title="Python"
job = client.request("POST", "/api/conversions", {
"sourceAssetId": "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d",
"inputFormat": "png",
"outputFormat": "avif",
"options": {"width": 1200, "quality": 70},
})
```
```json title="Response" status="201 Created"
{
"id": "d7e9f1a3-5b6c-4d8e-a0f2-3b4c5d6e7f80",
"status": "queued",
"category": "image",
"inputFormat": "png",
"outputFormat": "avif",
"sourceName": "hero.png",
"sourceBytes": 3145728,
"outputName": null,
"outputBytes": null,
"outputMime": null,
"progress": 5,
"stage": "queued",
"stageDetail": "Waiting for conversion capacity",
"serviceTier": "priority",
"timings": {},
"error": null,
"expiresAt": "2026-10-09T15:42:10.551820",
"savedAssetId": null,
"saveUploadSessionId": null,
"registered": true,
"createdAt": "2026-10-08T15:42:10.551820"
}
```
##### Convert a new file
Send `filename`, `size` (in bytes), and the formats. The response includes an `uploadUrl`; `PUT` the exact bytes there with `Content-Type: application/octet-stream`, then call `start`.
```bash
# 1. Create the job
curl -X POST https://api.steadylink.io/api/conversions \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "filename": "brochure.docx", "size": 482113, "inputFormat": "docx", "outputFormat": "pdf" }'
# 2. Upload the source to the returned uploadUrl
curl -X PUT "$UPLOAD_URL" -H "Content-Type: application/octet-stream" --data-binary @brochure.docx
# 3. Queue it
curl -X POST https://api.steadylink.io/api/conversions/d7e9f1a3-5b6c-4d8e-a0f2-3b4c5d6e7f80/start \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
`start` checks that the uploaded object exists and that its size matches `size` exactly. A missing upload returns `400` "Upload has not completed"; a size mismatch returns `400` "Uploaded file size does not match" and refunds the credit reservation. If a browser cannot reach the storage host, it can stream the bytes through the API instead with `PUT /api/conversions/{job_id}/upload`.
##### Follow progress and download
Poll `GET /api/conversions/{job_id}`. The `stage` moves through `queued`, `downloading`, `converting`, `verifying`, `uploading`, and `complete`, and `timings` reports milliseconds per stage when it finishes. A failed job has `status: "failed"` and an `error` with a `code` and `message`.
When `status` is `complete`, `GET /api/conversions/{job_id}/download` returns a JSON body with a `url` that downloads the result under its output name. The URL is valid for 15 minutes; call the route again for a fresh one. Calling it before the job completes returns `409` "Conversion is not ready".
To download several results in one file, `POST /api/conversions/archive` with 2 to 25 completed job IDs and `"format": "zip"` or `"tar.gz"`. The combined results can be up to 1 GB.
##### Guest jobs and access tokens
A job created without credentials is a guest job. Its creation response includes an `accessToken`, which is the only way to reach the job afterwards: send it as `X-Conversion-Token` on `start`, `GET`, `download`, `save`, and `DELETE`. Keep it for the hour the job lives. Jobs created with an API key belong to that exact key; another key in the same workspace cannot read them. Jobs created in a signed-in session belong to that user.
`GET /api/conversions/recent` lists the caller's unexpired, unfailed jobs (up to 100), which is how the dashboard restores its list after a reload. It requires an API key or a session.
#### Save a result into a bucket
Results are deleted when they expire. To keep one, save it into a bucket:
```bash
curl -X POST https://api.steadylink.io/api/conversions/d7e9f1a3-5b6c-4d8e-a0f2-3b4c5d6e7f80/save \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"bucketId": "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c",
"path": "converted/2026",
"filename": "hero.avif"
}'
```
The route answers `202 Accepted` and the save runs in the background through the same pipeline as a normal upload: the file is finalized, scanned, encrypted if the bucket requires it, and given a stable link. Poll the job until `savedAssetId` is set; that is the new file's asset ID. `path` is an optional folder (`..` is rejected), and `filename` defaults to the result's output name.
Saving uses workspace storage like any other file but no additional credits. Saving the same job twice does nothing the second time. Only the job's owner can save it, and a signed-in user needs a role that can edit files; viewers cannot save results or run conversions with workspace credits. With API keys, reading jobs needs `assets:read` and creating, starting, saving, and cancelling need `assets:write`.
#### Cancel or clean up
`DELETE /api/conversions/{job_id}` cancels a job that has not finished and refunds its credit reservation. On a finished job, it expires the job immediately, hiding it from recent lists; its stored files are removed shortly after.
#### Next steps
- [Conversions API](https://steadylink.io/docs/reference/api/conversions): Every conversion route with fields, statuses, and errors.
- [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits): How credits, storage, and delivery are counted per billing period.
- [Image transformations](https://steadylink.io/docs/guides/image-transformations): For resizing images on the fly, transform the delivery URL instead of converting.
### Notifications
Source: https://steadylink.io/docs/guides/notifications
Notifications tell the people in a workspace when something needs their attention: a client submitted files, a scheduled revision went live, a background job failed. Each person controls their own notifications, so a designer can get every submission by email while an admin only sees failures in the dashboard. This guide explains the events, how delivery works, and how to change your preferences.
Notifications are personal. They belong to a signed-in user, not to the workspace or an API key, which is why API keys cannot read or change them. For machine-to-machine events, use [workspace webhooks](https://steadylink.io/docs/reference/webhooks) instead.
#### Events
| Event | Shown as | Sent when |
| --- | --- | --- |
| `asset.replaced` | Asset replaced | A file is replaced with a new revision, or an earlier revision is made current again. |
| `request.submitted` | Request submitted | Someone submits files through an [asset request](https://steadylink.io/docs/guides/asset-requests) link, or an [upload widget](https://steadylink.io/docs/guides/upload-widget) upload is waiting for review. |
| `proofing.submitted` | Client picks sent | A client sends their favorites from a [proofing collection](https://steadylink.io/docs/guides/client-proofing). |
| `schedule.published` | Scheduled link published | A [scheduled revision](https://steadylink.io/docs/guides/replacement-requests) becomes current. |
| `asset.rolled_back` | Asset rolled back | A scheduled job restores an older revision, such as the rollback after a scheduled replacement. |
| `job.failed` | Processing failed | A background job, such as a conversion, website scan, or import, fails after its final retry. |
| `link.expiring` | Link expiring | An open asset request link expires within 24 hours. |
| `bandwidth.unusual` | Unusual bandwidth | The workspace's delivery bandwidth crosses a usage threshold for the billing period. |
| `traffic.unusual` | Unusual traffic | Listed in preferences, but SteadyLink does not currently send this event. |
Every member of the workspace receives each event, whatever their role, filtered by their own preferences. The same event is never delivered to the same person twice.
#### How delivery works
Each enabled event creates an entry in your **Inbox**, with a title, a short description, and a link to the file, request, or page involved. In addition, each event can go out through these channels:
| Channel | What you get |
| --- | --- |
| `in_app` | The inbox entry only. This is the default for every event. |
| `email` | An email to your account address. The subject is the notification title, and the message links to the item in SteadyLink. |
| `webhook` | Intended for workspace webhook endpoints. See the note below before choosing it. |
And each event has a timing mode:
- **`immediate`**: email and webhook deliveries are queued as soon as the event happens.
- **`digest`**: email and webhook deliveries are held until 17:00 UTC on the day of the event. An event that happens after 17:00 UTC goes out on the next worker run instead of waiting for the next day. Each notification is still sent as its own message; they are not combined into one email.
The inbox entry is always created immediately, whichever mode you choose.
**Warning: Webhook channel.**
Notification events are not among the event types that [workspace webhook endpoints](https://steadylink.io/docs/reference/webhooks) can subscribe to (`asset.revision.published`, `asset.scan.completed`, `asset.delivery.threshold`, `migration.completed`). Choosing the `webhook` channel for a notification currently ends in a `permanent_failure` entry in your delivery log rather than a webhook request. To get these signals into another system, subscribe a workspace webhook to the closest webhook event instead.
##### Delivery allowance
Email and webhook deliveries count against the workspace's monthly notification allowance. Inbox entries do not.
| Plan | External deliveries per billing period |
| --- | --- |
| Free | 100 |
| Personal | 1,000 |
| Pro | 10,000 |
| Business | 100,000 |
| Enterprise | 1,000,000 |
When the allowance runs out, email and webhook deliveries are skipped without an error until the next billing period. Inbox entries keep arriving, so nobody misses an event entirely.
#### Change your preferences
Open **Notifications** at the bottom of the dashboard sidebar and switch to **Preferences**. Events are grouped into **Files & collaboration** and **Workspace health**. Each row shows the event's current channels as badges and a switch to turn it on or off. Turning an event off stops both the inbox entry and any email for it. Each change saves immediately and applies only to you, in the current workspace.
Channels and the timing mode are set through the preferences API described below.
##### Defaults and personal overrides
Until you change an event, it uses the workspace default for that event, or, when the workspace has none, the built-in default: enabled, in-app only, immediate. The first time you save a preference for an event, SteadyLink stores a personal override for that one event; other events keep following the defaults. There is no reset action, so to go back to the default behavior, save the default values.
#### Use the preferences API
These routes require a signed-in user session (`Authorization: Bearer` with your session token) and act on the active workspace. An API key gets `403` with "User preferences require a user session" on reads and `422` on writes.
```bash title="Read your effective preferences"
curl https://api.steadylink.io/api/notifications/preferences \
-H "Authorization: Bearer $STEADYLINK_SESSION_TOKEN" \
-H "X-Workspace-Id: 2b4d6f80-1a3c-4e5f-8a7b-9c0d1e2f3a4b"
```
```json title="Response" status="200 OK"
{
"events": ["asset.replaced", "asset.rolled_back", "bandwidth.unusual", "job.failed", "link.expiring", "proofing.submitted", "request.submitted", "schedule.published", "traffic.unusual"],
"channels": ["email", "in_app", "webhook"],
"items": [
{ "eventType": "asset.replaced", "channels": ["in_app"], "mode": "immediate", "enabled": true, "inherited": false },
{ "eventType": "request.submitted", "channels": ["email", "in_app"], "mode": "immediate", "enabled": true, "inherited": false }
]
}
```
Each item is the effective preference for one event. `inherited` is `true` when the value comes from a workspace default rather than your own override or the built-in default.
To change an event, send the complete preference for it:
```bash title="Email me submissions once a day"
curl -X PUT https://api.steadylink.io/api/notifications/preferences \
-H "Authorization: Bearer $STEADYLINK_SESSION_TOKEN" \
-H "X-Workspace-Id: 2b4d6f80-1a3c-4e5f-8a7b-9c0d1e2f3a4b" \
-H "Content-Type: application/json" \
-d '{
"eventType": "request.submitted",
"channels": ["in_app", "email"],
"mode": "digest",
"enabled": true
}'
```
```json title="Response" status="200 OK"
{ "eventType": "request.submitted", "channels": ["email", "in_app"], "mode": "digest", "enabled": true }
```
Fields you leave out are not kept from the previous value: `channels` falls back to `["in_app"]`, `mode` to `immediate`, and `enabled` to `true`. Always send all four. The request is rejected with `422` "Invalid notification preference" when `eventType` is unknown, `channels` is empty or contains anything other than `in_app`, `email`, and `webhook`, or `mode` is not `immediate` or `digest`.
#### Read the inbox and delivery log
- **Inbox** (`GET /api/notifications`): your latest 100 notifications in the workspace and an `unread` count, which also drives the badge on the sidebar. Mark one read with `POST /api/notifications/{notification_id}/read`, or use **Mark all read** in the dashboard.
- **Delivery log** (`GET /api/notifications/deliveries`, or the **Delivery log** tab): your latest 100 email and webhook deliveries, each with its `channel`, `status` (`queued`, `delivered`, or `permanent_failure`), `attempts`, `lastError`, and `deliveredAt`. **Export CSV** downloads the log. Check it when an email you expected did not arrive: a `queued` entry in digest mode is waiting for 17:00 UTC, and a `permanent_failure` shows why it gave up.
Notification messages contain titles, short descriptions, and links into the dashboard, never file contents or signed download links. Opening the link still requires access to the workspace.
#### Next steps
- [Webhooks](https://steadylink.io/docs/reference/webhooks): Send signed events to your own systems, Slack, Discord, or Teams.
- [Asset requests](https://steadylink.io/docs/guides/asset-requests): The most common source of `request.submitted` notifications.
- [Workspaces and roles](https://steadylink.io/docs/concepts/workspaces-and-roles): Who belongs to a workspace and receives its notifications.
### Images in Next.js
Source: https://steadylink.io/docs/guides/nextjs-images
This guide is for Next.js developers who want `next/image` to load images from SteadyLink. You keep an asset ID in your code or CMS, `next/image` asks SteadyLink for each width it needs, and when someone replaces the image in SteadyLink, every page shows the new version within minutes. No rebuild, no new URL, and no image optimization running on your own servers.
#### How it works
`next/image` normally sends every image through the Next.js image optimizer. A **custom loader** replaces that step with a function that returns a URL. The SteadyLink loader in `@steadylink/sdk` turns an asset ID into a delivery URL with the width Next.js asks for:
```text
-> https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b?w=1200&fm=webp&q=75
```
Browsers fetch those URLs straight from SteadyLink, which resizes the current revision and caches each variant. Because the URL follows the file's current revision, a replacement reaches every `srcset` entry once the five-minute delivery cache expires.
#### Set up the loader
**Install the SDK**
```bash
npm install @steadylink/sdk
```
The loader has no runtime dependencies and needs no API key: it only builds URLs.
**Create a loader file**
```js title="steadylink-loader.js"
export { default } from "@steadylink/sdk/next-loader";
```
**Point next.config at it**
```js title="next.config.js"
module.exports = {
images: { loader: "custom", loaderFile: "./steadylink-loader.js" },
};
```
The loader now applies to every `next/image` in the app. Sources that are not SteadyLink references pass through unchanged (see [What counts as a SteadyLink source](#what-counts-as-a-steadylink-source)).
**Use an asset ID as the src**
```tsx title="app/page.tsx"
import Image from "next/image";
export default function Page() {
return (
);
}
```
Find the asset ID in the file's details in the dashboard (**File ID**), in the link itself, or in the `assetId` of an SDK upload result.
`width` and `height` here describe the image's intrinsic aspect ratio for layout, as with any `next/image`. They do not crop: SteadyLink resizes to the width in each `srcset` entry and keeps the original aspect ratio. With `fill`, give the parent a size and set `sizes` as usual.
#### What counts as a SteadyLink source
The loader recognizes three forms of `src` and leaves everything else alone:
| `src` | Result |
| --- | --- |
| An asset ID, such as `3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b` | `https://cdn.steadylink.io/a/{id}?w=...&fm=webp&q=...` |
| A path, such as `/a/3f2a9c1e-...` | Same, on the configured origin. |
| A full delivery URL, such as `https://cdn.steadylink.io/a/3f2a9c1e-...?v=3` | Same host, and the existing query (for example `v=3` or a signed `token`) is kept. `w`, `q`, and `fm` are added or overwritten. |
| Anything else: `/images/logo.png`, a static import, another domain | Returned unchanged, so local images keep loading. |
A plain value made only of letters, digits, `-`, and `_` is treated as an asset ID. A local `src="logo"` (with no slash or extension) would therefore be sent to SteadyLink; give local files a path such as `/logo.png`. Images that pass through unchanged are not resized by anyone, because the custom loader replaces the built-in optimizer for the whole app.
#### Customize the loader
Use `createSteadyLinkLoader` when you need a different origin or defaults:
```js title="steadylink-loader.js"
import { createSteadyLinkLoader } from "@steadylink/sdk/next-loader";
export default createSteadyLinkLoader({
origin: process.env.NEXT_PUBLIC_STEADYLINK_CDN_URL, // e.g. https://media.example.com
format: "webp",
quality: 75,
});
```
**createSteadyLinkLoader options**
- `origin` (string, default NEXT_PUBLIC_STEADYLINK_CDN_URL, then https://cdn.steadylink.io): Delivery origin for asset IDs and `/a/` paths. Use it for a verified custom delivery domain. A full URL in `src` keeps its own host.
- `format` ("webp" | "jpg" | "png" | false, default "webp"): Output format sent as `fm`, unless the `src` URL already has `fm`. `false` omits `fm`, which makes SteadyLink return JPEG for every resized image, not the source format. Use `"png"` for images that need lossless output.
- `quality` (number, default 75): Quality used when the `Image` has no `quality` prop. Clamped to 30–95.
- `fit` ("cover" | "contain" | "inside" | "outside"): Sent as `fit` unless the `src` URL already has one. Because the loader only sets a width, `fit` changes nothing unless the `src` URL also carries `h`.
Reference `process.env.NEXT_PUBLIC_STEADYLINK_CDN_URL` in your own loader file, as above, rather than relying on the default loader to find it. Next.js inlines `NEXT_PUBLIC_` variables only where your code names them directly, so this keeps the origin identical in server-rendered HTML and in the browser. A custom domain must be added and verified for your workspace before it serves files; see the [Platform API](https://steadylink.io/docs/reference/api/platform).
#### Control how many variants you create
Each distinct combination of width, quality, and format is a stored variant. By default SteadyLink allows 100 variants per file and 10,000 per workspace; a request for a new variant beyond that returns `429` (existing variants keep working). `next/image` generates one URL per configured width, which is usually well within the per-file limit, but a large catalog can approach the workspace limit. Keep the set small and fixed:
```js title="next.config.js"
module.exports = {
images: {
loader: "custom",
loaderFile: "./steadylink-loader.js",
deviceSizes: [640, 828, 1200, 1920],
imageSizes: [64, 128, 256],
},
};
```
Avoid passing many different `quality` values for the same image; each one multiplies the variants. Asking for a width larger than the original is harmless: SteadyLink's default fit never enlarges, so it returns the original size.
#### Crops and fixed aspect ratios
The loader only controls width. For a crop to an exact shape, such as square avatars or 1200×630 social cards, put the crop in the `src` URL and render a single size:
```tsx
```
The loader adds `w` for each `srcset` entry while `h=400` stays fixed, so only the entry at 400 pixels is square. Set `sizes` and the device sizes so that entry is the one browsers pick, or save a [focal point](https://steadylink.io/docs/guides/image-transformations#control-the-crop-with-focal-points) and pre-crop the image when you need several sizes of the same shape.
#### Pinned versions and private images
- **Pin a version** by using a full URL with `v`, for example `src="https://cdn.steadylink.io/a/3f2a...?v=3"`. The image then never changes, and the browser can cache it for a year.
- **Private images** need a signed link. Pass the signed URL (with its `token`) as `src`; the loader keeps the token. Private responses are never stored by shared caches, and the image breaks when the token expires, so avoid embedding short-lived signed links in pages that are statically generated or cached for longer than the link lives. See [Private links](https://steadylink.io/docs/security/private-links).
#### Files that cannot be resized
SVG, PDF, and other non-raster files return `415` when asked for a width. Render them without the loader by passing the full link and `unoptimized`:
```tsx
```
#### What happens when the image is replaced
1. Someone replaces the file in the dashboard, with `steadylink.replace()`, or with `steadylink replace`.
2. SteadyLink scans the new revision. During the scan, requests that reach SteadyLink get `423`, which a browser shows as a broken image for that moment.
3. Each `srcset` URL is cached for five minutes. As copies expire, browsers and edges fetch the new revision, and SteadyLink generates new variants on first request.
No Next.js rebuild or revalidation is involved, because the HTML never contained the image bytes or a version-specific URL. To roll back, restore the previous revision; the same URLs serve it again.
#### Next steps
- [Image transformations](https://steadylink.io/docs/guides/image-transformations): Every delivery parameter, focal points, and caching.
- [TypeScript SDK](https://steadylink.io/docs/reference/sdk): `link()`, `assetUrl()`, uploads, and replacements from your server code.
- [Upload and replace files](https://steadylink.io/docs/guides/upload-and-replace): Publish a new version behind the same link.
### Connect rclone in one command
Source: https://steadylink.io/docs/guides/rclone
This guide is for anyone who wants SteadyLink buckets on their own computer or server: synced folders, a mounted drive, or a nightly backup. One command installs [rclone](https://rclone.org), adds a remote called `steadylink`, and checks that your key works.
The scripts are open source in [SteadyLink-io/rclone-setup](https://github.com/SteadyLink-io/rclone-setup). The files served from `steadylink.io/install` are copies of the ones in that repository, so you can read exactly what runs.
#### Before you start
You need an app key: an access key ID that starts with `SL` and its secret. The secret is shown once, when the key is created. See [App keys](https://steadylink.io/docs/reference/s3#app-keys) for how to create one, and make it read-only or limited to one bucket if that is all the computer needs.
#### Run the setup
**macOS and Linux**
```bash
curl -fsSL https://steadylink.io/install/rclone.sh | sh
```
To skip the key prompts, put the key in front of `sh`:
```bash
curl -fsSL https://steadylink.io/install/rclone.sh | STEADYLINK_ACCESS_KEY_ID=SLXXXXXXXXXXXXXXXXXX STEADYLINK_SECRET_ACCESS_KEY=YOUR_SECRET sh
```
**Windows**
In PowerShell:
```powershell
irm https://steadylink.io/install/rclone.ps1 | iex
```
To skip the key prompts:
```powershell
$env:STEADYLINK_ACCESS_KEY_ID='SLXXXXXXXXXXXXXXXXXX'; $env:STEADYLINK_SECRET_ACCESS_KEY='YOUR_SECRET'; irm https://steadylink.io/install/rclone.ps1 | iex
```
A key typed into a one-line command is saved in your shell history. Running the command without the key and typing the secret at the prompt, where it is hidden, avoids that.
#### What the script does
**Installs rclone**
If rclone 1.65 or newer is not installed, the script installs it: with winget on Windows, Homebrew on macOS when you have it, and rclone's official installer otherwise. On Linux it uses `sudo` only when you are not root. Fallback downloads come from downloads.rclone.org and are checked against the published SHA-256 sums.
**Creates the remote**
It runs `rclone config create steadylink s3` with the endpoint `https://api.steadylink.io/s3`, region `auto`, and path-style addressing. If a remote called `steadylink` already exists, it asks before replacing it. The secret is stored in rclone's config file and never printed.
**Checks the key**
It runs `rclone lsd steadylink:` and lists the buckets the key can see. If the key is rejected, it explains the error in plain words. See [Troubleshooting](#troubleshooting).
**Offers a drive and a backup**
In an interactive terminal it asks whether to mount SteadyLink and whether to schedule a backup. Both default to no.
Running the script again is safe. It updates the remote, mount, or backup it created before instead of adding another.
##### Options
| Variable | Default | Meaning |
| --- | --- | --- |
| `STEADYLINK_ACCESS_KEY_ID` | | App key ID |
| `STEADYLINK_SECRET_ACCESS_KEY` | | App key secret |
| `STEADYLINK_ENDPOINT` | `https://api.steadylink.io/s3` | S3 endpoint |
| `STEADYLINK_REMOTE` | `steadylink` | Name of the rclone remote |
| `STEADYLINK_NONINTERACTIVE` | | `1` creates the remote and stops, with no prompts |
#### Use the remote
```bash
rclone lsd steadylink: # list buckets
rclone copy ~/Pictures steadylink:my-bucket/pictures --progress
rclone sync ~/Documents steadylink:my-bucket/documents
rclone check ~/Documents steadylink:my-bucket/documents
```
Uploading a file that already exists adds a [revision](https://steadylink.io/docs/concepts/revisions) behind the same stable link, so published links keep serving the latest version. rclone keeps modification times in object metadata, so `sync` and `check` skip files that have not changed.
#### Mount as a drive
When you answer yes to the mount question:
- **Windows** mounts on the first free letter from `S:`, using `rclone mount --vfs-cache-mode full --network-mode`. It needs [WinFsp](https://winfsp.dev), which the script offers to install with winget. A Task Scheduler entry, `SteadyLink Mount`, mounts it again at each sign-in.
- **macOS** mounts at `~/SteadyLink`. Without macFUSE or FUSE-T it uses `rclone nfsmount`, which needs nothing extra. A launchd agent, `io.steadylink.mount`, starts it at login.
- **Linux** mounts at `~/SteadyLink` through a systemd user service, `steadylink-mount.service`. It needs the `fuse3` package.
The local cache lets programs open and edit files as they would on a normal disk. Changes upload a few seconds after a file is closed.
#### Schedule a backup
When you answer yes to the backup question, the script asks for a local folder, a bucket, a folder inside the bucket, a mode, and an hour (or hourly).
- `copy` uploads new and changed files and never deletes anything in SteadyLink. It is the default.
- `sync` makes the bucket folder match the local one. Files deleted locally are moved to `/.deleted/` instead of being removed.
The job runs with Task Scheduler on Windows, launchd on macOS, and a systemd user timer on Linux, and each run is logged to a file. The [repository README](https://github.com/SteadyLink-io/rclone-setup#scheduled-backup) lists the exact names and log paths.
#### Uninstall
**macOS and Linux**
```bash
curl -fsSL https://steadylink.io/install/rclone.sh | sh -s -- --uninstall
```
**Windows**
```powershell
& ([scriptblock]::Create((irm https://steadylink.io/install/rclone.ps1))) -Uninstall
```
This removes the mount and backup jobs. It asks before removing the `steadylink` remote or rclone itself, and never deletes files in SteadyLink or on your computer.
#### Troubleshooting
| Error | What it means |
| --- | --- |
| `InvalidAccessKeyId` | SteadyLink does not know the key ID. Check it was copied in full and has not been revoked. |
| `SignatureDoesNotMatch` | The key ID is right but the secret is not. Copy it again, or create a new key if it is lost. |
| `AccessDenied` | The key was revoked, its owner lost access to the workspace, or a read-only key tried to write. |
| `RequestTimeTooSkewed` | Your computer's clock is off. Turn on automatic time. |
| `SlowDown` | The key passed its rate limit. rclone retries on its own. |
| `QuotaExceeded` | The workspace has used its storage allowance. |
See [Errors](https://steadylink.io/docs/reference/s3#errors) in the S3 reference for the full list.
#### Next steps
- [S3-compatible API](https://steadylink.io/docs/reference/s3): Connection details, other tools, and where SteadyLink differs from Amazon S3.
- [Revisions](https://steadylink.io/docs/concepts/revisions): What happens when a sync overwrites a file that already has a link.
- [rclone-setup on GitHub](https://github.com/SteadyLink-io/rclone-setup): The scripts, plus recipes for restic, Synology, TrueNAS, and Docker Compose.
### Use SteadyLink in Claude, ChatGPT, Cursor and more
Source: https://steadylink.io/docs/guides/mcp
SteadyLink works as a connector (an MCP server) in AI tools. Once it's added, you can ask for things like "upload this report and give me a link" or "replace the price list but keep the link", and the assistant does it in your workspace.
Pick your app to add SteadyLink in one click. Every other tool uses the same address, `https://mcp.steadylink.io/mcp`.
The first time a tool uses it, a SteadyLink page opens in your browser. You sign in, pick the workspace the tool can use, choose whether it can change files or only read them, and select **Allow**.
**Note: Needs a paid plan.**
AI connectors work with workspaces on a paid plan. Workspaces on the free Hobby plan show up on the sign-in page but can't be picked. See [plans](https://steadylink.io/pricing).
#### Claude
Works in claude.ai, Claude Desktop and the Claude mobile apps. A connector you add once shows up in all of them.
**[Add SteadyLink to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=SteadyLink&connectorUrl=https%3A%2F%2Fmcp.steadylink.io%2Fmcp)**. The link opens Claude's add-connector dialog with the name and URL filled in, and you confirm it there.
To add it by hand:
**Open connectors**
In Claude, go to **Customize > Connectors** and select **Add custom connector**.
**Paste the URL**
Name it `SteadyLink`, paste `https://mcp.steadylink.io/mcp` and select **Add**. Leave the OAuth fields empty.
**Connect**
Select **Connect**, sign in to SteadyLink and pick a workspace. In a chat, turn SteadyLink on from the **+** menu under **Connectors**.
On the Free plan Claude allows one custom connector.
**Claude Team and Enterprise.** Members can't add custom connectors themselves. An Owner adds SteadyLink once for the organization with [this prefilled link](https://claude.ai/admin-settings/connectors?modal=add-custom-connector&connectorName=SteadyLink&connectorUrl=https%3A%2F%2Fmcp.steadylink.io%2Fmcp) (or **Organization settings > Connectors > Add > Custom > Web**). Each member then finds it under **Customize > Connectors** and selects **Connect** to sign in with their own SteadyLink account.
#### Claude Code
```bash
claude mcp add --transport http steadylink https://mcp.steadylink.io/mcp
```
Then run `/mcp` inside Claude Code and choose SteadyLink to sign in. If you already added SteadyLink on claude.ai, Claude Code picks it up from your account and you can skip this.
To share it with your team through the repo, add it to `.mcp.json` instead:
```json title=".mcp.json"
{
"mcpServers": {
"steadylink": { "type": "http", "url": "https://mcp.steadylink.io/mcp" }
}
}
```
#### ChatGPT
SteadyLink isn't in the ChatGPT plugin directory yet. A listing is on the way, and until then you can add it as a custom server in developer mode.
**Turn on developer mode**
In ChatGPT, open **Settings > Apps > Advanced settings** and turn on **Developer mode**. On Business and Enterprise workspaces an admin may need to allow it first.
**Add the server**
Go to [chatgpt.com/plugins](https://chatgpt.com/plugins), select the **+** button, then **Add custom MCP server**. Name it `SteadyLink`, paste `https://mcp.steadylink.io/mcp` as the URL and choose OAuth for authentication.
**Connect**
Accept the developer mode warning, select **Create as a plugin**, and sign in to SteadyLink when asked. In a new chat, type `@` and pick SteadyLink.
If **Add custom MCP server** isn't there, your ChatGPT plan or workspace doesn't allow custom servers. OpenAI doesn't publish an exact plan list, and reports suggest some plans only allow read access for custom servers. ChatGPT is also the one app that can hand a file you attach in the chat straight to SteadyLink.
#### Codex
The Codex CLI, the Codex IDE extension and the ChatGPT desktop app share one config file. Add this to `~/.codex/config.toml`:
```toml title="~/.codex/config.toml"
[mcp_servers.steadylink]
url = "https://mcp.steadylink.io/mcp"
```
Or run `codex mcp add steadylink --url https://mcp.steadylink.io/mcp`. Then sign in:
```bash
codex mcp login steadylink
```
#### Cursor
**[Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=steadylink&config=eyJ1cmwiOiJodHRwczovL21jcC5zdGVhZHlsaW5rLmlvL21jcCJ9)**. Cursor asks you to confirm, then sends you to the SteadyLink sign-in page.
Or add it to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project):
```json title="mcp.json"
{
"mcpServers": {
"steadylink": { "url": "https://mcp.steadylink.io/mcp" }
}
}
```
#### VS Code and GitHub Copilot
**[Install in VS Code](vscode:mcp/install?%7B%22name%22%3A%22steadylink%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.steadylink.io%2Fmcp%22%7D)** or **[Install in VS Code Insiders](vscode-insiders:mcp/install?%7B%22name%22%3A%22steadylink%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.steadylink.io%2Fmcp%22%7D)**.
From a terminal:
```bash
code --add-mcp '{"name":"steadylink","type":"http","url":"https://mcp.steadylink.io/mcp"}'
```
Or add it to `.vscode/mcp.json`. VS Code uses `servers`, not `mcpServers`:
```json title=".vscode/mcp.json"
{
"servers": {
"steadylink": { "type": "http", "url": "https://mcp.steadylink.io/mcp" }
}
}
```
#### Kiro, Goose and LM Studio
- **Kiro.** [Add to Kiro](https://kiro.dev/launch/mcp/add?name=steadylink&config=%7B%22url%22%3A%22https%3A%2F%2Fmcp.steadylink.io%2Fmcp%22%7D), or add `"steadylink": { "url": "https://mcp.steadylink.io/mcp" }` under `mcpServers` in `~/.kiro/settings/mcp.json`. This also covers Kiro CLI, which replaced the Amazon Q Developer CLI.
- **Goose.** [Add to Goose](goose://extension?url=https%3A%2F%2Fmcp.steadylink.io%2Fmcp&type=streamable_http&id=steadylink&name=SteadyLink&description=Permanent%20file%20links).
- **LM Studio.** LM Studio doesn't document OAuth sign-in, so it connects with an [API key](#use-an-api-key-instead). [Add to LM Studio](lmstudio://add_mcp?name=steadylink&config=eyJ1cmwiOiJodHRwczovL21jcC5zdGVhZHlsaW5rLmlvL2tleS9tY3AiLCJoZWFkZXJzIjp7IkF1dGhvcml6YXRpb24iOiJCZWFyZXIgc2xrX1lPVVJfS0VZIn19), then open its `mcp.json` and replace `slk_YOUR_KEY` with your key.
#### Other editors and agents
These have no install link, so paste the config into the file named in each tab. Each tool spells the server entry a little differently.
**Windsurf / Devin Desktop**
Add to `mcp_config.json` (`~/.config/devin/mcp_config.json`, or `%APPDATA%\devin\mcp_config.json` on Windows). Windsurf is now Devin Desktop.
```json title="mcp_config.json"
{
"mcpServers": {
"steadylink": { "serverUrl": "https://mcp.steadylink.io/mcp" }
}
}
```
On Enterprise, MCP has to be turned on by an admin, and if your admin keeps an allowlist, its server ID must be `steadylink`, matching the key above.
**Zed**
Add to Zed's `settings.json`, or use **Settings > AI > MCP Servers > Add Server > Add Remote Server**. Leave out any `Authorization` header so Zed runs the sign-in.
```json title="settings.json"
{
"context_servers": {
"steadylink": { "url": "https://mcp.steadylink.io/mcp" }
}
}
```
**JetBrains**
In AI Assistant, open **Settings > Tools > AI Assistant > Model Context Protocol (MCP)**, select **Add**, choose **HTTP**, paste this and select **Apply**. JetBrains doesn't document how it handles sign-in, so if no SteadyLink page opens, use [mcp-remote](#any-other-tool).
```json
{
"mcpServers": {
"steadylink": { "url": "https://mcp.steadylink.io/mcp" }
}
}
```
**Cline**
Use **MCP Servers > Remote Servers** with transport **Streamable HTTP**, or add this to `cline_mcp_settings.json`. Without `type`, Cline falls back to an older transport.
```json title="cline_mcp_settings.json"
{
"mcpServers": {
"steadylink": { "type": "streamableHttp", "url": "https://mcp.steadylink.io/mcp" }
}
}
```
**Roo Code**
Add to `.roo/mcp.json` in your project or to Roo's global `mcp_settings.json`. Roo's docs don't cover OAuth, so if no SteadyLink page opens, use [mcp-remote](#any-other-tool).
```json title=".roo/mcp.json"
{
"mcpServers": {
"steadylink": { "type": "streamable-http", "url": "https://mcp.steadylink.io/mcp" }
}
}
```
**Continue**
Add to `config.yaml`. Continue only uses MCP tools in agent mode.
```yaml title="config.yaml"
mcpServers:
- name: SteadyLink
type: streamable-http
url: https://mcp.steadylink.io/mcp
```
**Gemini / Antigravity CLI**
Gemini CLI was replaced by Antigravity CLI in June 2026. With Gemini CLI:
```bash
gemini mcp add --transport http steadylink https://mcp.steadylink.io/mcp
```
Antigravity CLI reportedly reads `~/.gemini/antigravity-cli/mcp_config.json` and needs `serverUrl` (a `url` key fails silently). That comes from migration guides, not Google's own docs, so check it if the server doesn't load.
```json title="mcp_config.json"
{
"mcpServers": {
"steadylink": { "serverUrl": "https://mcp.steadylink.io/mcp" }
}
}
```
##### Any other tool
If a tool can add a remote MCP server by URL, give it `https://mcp.steadylink.io/mcp`. If it can only run local (stdio) servers, bridge to SteadyLink with [mcp-remote](https://github.com/geelen/mcp-remote), which needs Node.js 18 or later and opens the sign-in page itself. The command is `npx -y mcp-remote https://mcp.steadylink.io/mcp`, and in most config files it looks like this:
```json
{
"mcpServers": {
"steadylink": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.steadylink.io/mcp"] }
}
}
```
#### Use an API key instead
Scripts, CI jobs and headless agents can't open a browser to sign in. For those, use a workspace API key on a separate address:
```text
https://mcp.steadylink.io/key/mcp
Authorization: Bearer slk_...
```
Create the key in **Dashboard > Developers > API keys** (workspace admins and owners can do this) with the **Read files** scope, plus **Change files** if it should upload. For example, in Claude Code:
```bash
claude mcp add --transport http steadylink https://mcp.steadylink.io/key/mcp --header "Authorization: Bearer $STEADYLINK_API_KEY"
```
The same address works with the [Claude API MCP connector](https://platform.claude.com/docs/en/agents-and-tools/mcp-connector) (pass the key as `authorization_token`) and with the [OpenAI Responses API](https://developers.openai.com/api/docs/guides/tools-connectors-mcp) (pass it as `authorization`).
Keep in mind that an API key belongs to the workspace, not to a person, so its actions aren't tied to anyone's name, and ChatGPT doesn't accept API keys at all. Don't use the key address in an app that can sign in normally, because Cursor and some other apps ignore a configured header when the address also offers sign-in, which is why keys get their own address.
**Warning.**
Never paste an `slk_` key into a chat. Put it in an environment variable or your tool's secret settings.
#### What it can do
| Tool | What it does |
| --- | --- |
| `whoami` | Says which account, workspace and plan the connection uses. |
| `list_files` | Lists or searches files, with each file's link, size and version. |
| `get_file` | Shows one file's details, permanent link and version history. |
| `get_usage` | Shows how much storage is used and left, and the largest file you can upload. |
| `upload_file` | Adds a new file. It never overwrites a file with the same name. |
| `replace_file` | Puts a new version behind an existing link. |
| `finish_upload` | Finishes an upload once the file has arrived and passed the malware scan, then returns the link. |
| `restore_version` | Rolls a link back to an earlier version. |
If you connected with **Read-only** on, only the first four work. No tool can delete files or change workspace settings, and everything the assistant does follows your own role in the workspace.
Things to try:
- "Upload this CSV to SteadyLink and give me a permanent link."
- "Replace the price list PDF with this new one, but keep the link."
- "Which of my files are over 100 MB?"
- "The last replace was a mistake. Put the previous version back."
##### How uploads work
AI apps pass files around in different ways, so SteadyLink accepts a file three ways and the assistant picks the one that fits.
- **Small files** (text, or a small file the assistant can read) go straight into the request.
- **Links.** If the file is already online, the assistant sends its address and SteadyLink fetches it. Files you attach in ChatGPT arrive this way.
- **An upload page.** For anything bigger, SteadyLink returns a one-time upload link. In Claude's apps you open it and drop the file in. Tools with a terminal, like Claude Code, Codex and Cursor, can send the file there themselves.
Every new file is scanned before its link goes live, the same as an upload from the dashboard.
##### Replacing keeps the link
Replacing a file publishes a new version behind the same permanent link, so every page and message that uses the link shows the new file. Because that's the one change that affects people who already have the link, apps that support it (Claude does) ask you to confirm each replace. The previous version is kept, and `restore_version` puts it back.
#### Disconnect an app
Open **Dashboard > Developers > Connected apps** and select **Disconnect** next to the app. It loses access on its next request. Workspace admins and owners see every connected app in the workspace, and members see their own. You can also remove SteadyLink inside the app, but disconnecting in SteadyLink is what cuts off access.
A connection is tied to one workspace. To switch workspaces, disconnect and connect again.
#### Troubleshooting
- **A workspace can't be picked.** It's on the Hobby plan, or it requires single sign-on. Upgrade the workspace, or use the **Sign in with single sign-on** button on the sign-in page.
- **An install button does nothing.** The `cursor://`, `vscode:`, `goose://` and `lmstudio://` links only work when the app is installed. Use the config shown next to the button instead.
- **Claude Team or Enterprise members can't add it.** An Owner has to add SteadyLink for the organization first (see [Claude](#claude)).
- **Cursor or Windsurf Enterprise blocks it.** Your admin needs to allow `https://mcp.steadylink.io/mcp`, and in Windsurf the server ID must be `steadylink`.
- **"Unverified app" on the sign-in page.** SteadyLink shows that for apps it hasn't reviewed, along with where the app sends you back. Only allow it if you started the connection yourself.
- **The assistant says it can't upload.** The connection is read-only, or your workspace role can't change files. Disconnect and connect again with **Read-only** off, or ask a workspace admin for a different role.
- **The app keeps asking you to sign in.** Disconnect it in **Connected apps**, remove SteadyLink in the app, and add it again.
#### Next steps
- [Upload and replace files](https://steadylink.io/docs/guides/upload-and-replace): How versions, scanning and permanent links work.
- [Set up SteadyLink with an AI assistant](https://steadylink.io/docs/guides/ai-assistants): Give an assistant the docs so it can write integration code.
- [Workspaces and roles](https://steadylink.io/docs/concepts/workspaces-and-roles): What each role can do in a workspace.
### Set up SteadyLink with an AI assistant
Source: https://steadylink.io/docs/guides/ai-assistants
AI assistants are good at setup work: writing an upload script, wiring SteadyLink into your app, or configuring rclone. They do it well only when they read the real docs instead of guessing. SteadyLink publishes its documentation in two plain-text files made for that.
| File | What it holds | Use it when |
| --- | --- | --- |
| [llms.txt](https://steadylink.io/llms.txt) | A short index: what SteadyLink is, the key integration facts, and a link to every docs page. | The assistant can browse the web and will open the pages it needs. |
| [llms-full.txt](https://steadylink.io/llms-full.txt) | The full text of every docs page in one file, including every API endpoint, SDK method and code example. | You want the assistant to have everything at once, or it can't follow links. |
Both files are rebuilt from the docs on every release, so they always match this site.
This page is about getting help writing SteadyLink code. To let Claude, ChatGPT, Cursor and other AI apps upload files and hand back permanent links themselves, [connect SteadyLink as an AI app](https://steadylink.io/docs/guides/mcp).
#### Ask an assistant to set things up
**Give it the docs**
In ChatGPT, Claude, Gemini or another assistant that can browse, paste the link `https://steadylink.io/llms-full.txt` at the start of your message. If it can't open links, download [llms-full.txt](https://steadylink.io/llms-full.txt) and attach the file instead.
**Say what you want to build**
Be specific about your stack and the result you want. For example:
```text
Read https://steadylink.io/llms-full.txt first. Then help me write a Node.js
script that uploads every PDF in ./catalogs to a SteadyLink bucket called
"catalogs" and prints each file's permanent link. When I run it again, it
should replace files that already exist so their links stay the same.
```
**Keep your key out of the chat**
The assistant only needs to know that a key exists. Create the key yourself in **Dashboard > Developers**, store it in an environment variable such as `STEADYLINK_API_KEY`, and have the code read it from there. Never paste a full `slk_` key or S3 secret into a chat.
#### In code editors
Cursor, VS Code with GitHub Copilot, Windsurf, Claude Code and similar tools can read the same file.
- **Cursor:** add `https://steadylink.io/llms-full.txt` under **Settings > Indexing & Docs**, then mention it with `@` in chat.
- **Claude Code, Codex and other terminal agents:** start your request with "Read https://steadylink.io/llms-full.txt first", or save the file into your project and reference it.
- **Any editor:** save `llms-full.txt` next to your code so the assistant picks it up as project context.
#### Good things to ask for
- "Set up rclone so I can sync a folder to SteadyLink" (see also [Connect rclone in one command](https://steadylink.io/docs/guides/rclone)).
- "Add an upload button to my Next.js app that sends files straight to SteadyLink without exposing my API key" (see [Browser uploads](https://steadylink.io/docs/guides/browser-uploads)).
- "Write a script that replaces our price list PDF every Monday so the link on our site never changes" (see [Upload and replace files](https://steadylink.io/docs/guides/upload-and-replace)).
- "Serve our product images through `next/image` with resizing" (see [Images in Next.js](https://steadylink.io/docs/guides/nextjs-images)).
If an assistant gives you an endpoint, parameter or SDK method you can't find in these docs, it is guessing. Ask it to quote the part of `llms-full.txt` it used, or check the [REST API reference](https://steadylink.io/docs/reference/api).
### Concepts
Source: https://steadylink.io/docs/concepts
SteadyLink separates a file's identity from its bytes. The identity, and the link that points at it, never changes. The bytes change every time you publish a revision. Everything else in the product follows from that split.
- [Stable links](https://steadylink.io/docs/concepts/stable-links): How one link follows the current revision, and how to pin a specific one.
- [Revisions](https://steadylink.io/docs/concepts/revisions): What a revision stores, how promotion and rollback work, and what deletion removes.
- [Upload lifecycle](https://steadylink.io/docs/concepts/uploads): The states every upload passes through, from session to scanned and ready.
- [Workspaces and roles](https://steadylink.io/docs/concepts/workspaces-and-roles): Who can do what, and how API keys fit in.
- [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits): Storage, delivery, operations, and what happens at each threshold.
### Stable links
Source: https://steadylink.io/docs/concepts/stable-links
Every file in SteadyLink has one permanent delivery link. When you publish a new version, the link stays the same and starts serving the new bytes. This page explains what that link points at, how to pin an older revision on purpose, how caching affects what people see after a change, and the few situations where a link can stop working.
#### Identity and bytes are separate
A file (the API calls it an asset) has an ID that is assigned once, when the file is first uploaded, and never changes. The bytes live in **revisions**. Each upload to the same file adds a revision, and the file has exactly one **current** revision at a time.
The stable link is built from the file ID, not from the bytes or the filename:
```text title="Stable link"
https://cdn.steadylink.io/a/3f2a9c1e-7b4d-4e8a-9c61-2d5f0a8b7e13
```
Because the link names the identity, anything that changes the bytes leaves the link alone: replacing the file, restoring an older revision, or scheduling a revision to publish later.
##### What creates a new revision instead of a new file
Uploading a file to a bucket path that already holds a file adds a revision to that existing file. Uploading `press/logo.png` twice gives you one file with revisions 1 and 2, not two files. The same applies to replacements from the dashboard, the API, the SDKs, the CLI, and approved [replacement requests](https://steadylink.io/docs/guides/replacement-requests).
Renaming or moving a file inside its bucket keeps its ID, its revisions, and its link.
For a task-focused walkthrough, see [how to replace an image without changing its URL](https://steadylink.io/blog/replace-image-without-changing-url) or [how to replace any file and keep the same URL](https://steadylink.io/blog/replace-file-keep-same-url).
**Note.** Links use the file ID. There are no custom, human-readable link names for files. A bucket can be given an optional `slug` when it is created, but a bucket slug does not deliver a file.
#### The two kinds of link
| Link | Serves | Cache lifetime | Use it when |
| --- | --- | --- | --- |
| `/a/{asset_id}` | The current revision, whatever it is right now | 5 minutes | You want people to always get the latest version: websites, emails, QR codes, printed material |
| `/a/{asset_id}?v=3` | Revision 3, always | 1 year, immutable | A consumer must not change without a deliberate update: a legal document someone signed, a build artifact, a reproducible test fixture |
The revision number in `?v=` is the `versionNumber` from the file's revision history. A pinned link works for as long as that revision is retained. Delete the revision and the pinned link returns `404`.
Image transformations work on both kinds of link. `/a/{asset_id}?w=1200&fm=webp` resizes the current revision; `/a/{asset_id}?v=3&w=1200&fm=webp` resizes revision 3. See [Image transformations](https://steadylink.io/docs/guides/image-transformations).
#### Caching
Delivery responses carry `Cache-Control` headers that browsers and CDNs follow:
| Response | `Cache-Control` |
| --- | --- |
| Stable link, public file | `public, max-age=300, stale-while-revalidate=3600, stale-if-error=3600` |
| Pinned link (`?v=`), public file | `public, max-age=31536000, immutable` |
| Private file (any link with a valid token) | `private, no-store, max-age=0` |
What this means in practice:
- **After a replacement or a restore**, the SteadyLink API serves the new current revision immediately. A browser or CDN that already holds the previous response can keep showing it for up to five minutes, and briefly longer while it revalidates in the background.
- **Pinned responses never change.** They are cached for a year and marked immutable, so a cache never asks again. That is safe because revision bytes are never edited in place.
- **Private files are never stored in shared caches.** Every request reaches SteadyLink and the token is checked each time, which is what makes revoking a signed link take effect at once.
If a page still shows the old file right after you replace it, wait five minutes or hard-refresh. To prove the new bytes are live without waiting, open the pinned link for the new revision, for example `?v=4`.
#### Replacing, restoring, and rolling back
**Replacing** uploads new bytes. When the upload finishes, SteadyLink stores them as a new revision and makes it current. The previous revision is kept in the revision history.
**Restoring** (the API calls it promoting) makes an older revision current again. No bytes are copied and no new revision is created; the current pointer moves. A rollback is the same operation. When a public file's revision is promoted, SteadyLink also asks the CDN to fetch the new current version so the first visitor does not pay for a cold cache.
Only revisions that passed the malware scan can be restored. See [Revisions](https://steadylink.io/docs/concepts/revisions#make-an-older-revision-current).
##### The scan window after a replacement
When malware scanning is on (it is on in production), a new revision becomes current as soon as the upload is finalized, and it is scanned right after. Until the scan finishes, the stable link responds with `423 Locked` instead of serving unscanned bytes. Scans usually take seconds; very large files take longer.
What happens when the replacement is infected depends on when the malware is found. The infected bytes are never served in either case.
- **Caught before commit.** Small files, files whose hash is already known, and bytes with a cached verdict are scanned inline. An infected file is rejected with `400` (`Upload blocked by malware scan`), no revision is created, and the previous revision keeps serving.
- **Caught by the background scan.** Otherwise the new revision is already current when the scan finds malware. It is marked infected and the file is left with **no current revision**, so the stable link returns `404` until you restore an earlier clean revision.
If a gap of a few seconds is not acceptable, publish the change on a schedule instead: upload or approve the new version with a future publish time, and SteadyLink switches the link to it only at that time. See [Replacement requests](https://steadylink.io/docs/guides/replacement-requests).
#### What changes and what never changes
These stay the same for the life of the file:
- The file ID and the stable link `/a/{asset_id}`.
- The bytes behind any pinned link `?v=N`, while that revision exists.
- Revision numbers already assigned to retained revisions.
These can change at any time:
- The bytes, size, and content type served by the stable link.
- Image dimensions, and so any transformed variant of the stable link.
- Whether the link opens for everyone or needs a token, when someone switches the file between public and private. See [Private links](https://steadylink.io/docs/security/private-links).
#### When a link stops working
A stable link only breaks when the identity goes away or delivery is refused on purpose:
| Situation | Response |
| --- | --- |
| The file is private and the request has no valid token | `403` |
| The current revision is still being scanned | `423` |
| The current revision was found to be infected | `404` until a clean revision is restored |
| The last remaining revision was deleted, which deletes the file | `404` |
| The bucket holding the file was deleted | `404` |
| A pinned link names a revision that was deleted | `404` |
Deleting a bucket or a file is immediate and has no undo. Download anything you want to keep first.
#### Next steps
- [Revisions](https://steadylink.io/docs/concepts/revisions): What each revision stores, labels and notes, and how deletion works.
- [Private links](https://steadylink.io/docs/security/private-links): Public, private, and signed access to the same stable link.
- [Delivery API](https://steadylink.io/docs/reference/api/delivery): Every delivery parameter, status code, and header.
### Revisions
Source: https://steadylink.io/docs/concepts/revisions
Every time a file's bytes change, SteadyLink keeps the new bytes as a numbered revision and leaves the older ones in place. This page is for anyone who manages file history: you will learn what a revision records, how to make an older one current, and what happens to links and storage when you delete revisions.
#### What a revision stores
Revisions are numbered from 1. Each one is an immutable copy of the bytes plus the facts SteadyLink recorded about them:
| Field | Meaning |
| --- | --- |
| `versionNumber` | The number used in pinned links (`?v=3`) and revision API routes. |
| `byteSize` | Size of the stored original in bytes. |
| `mime` | Content type, checked against the bytes during upload. |
| `width`, `height` | Pixel dimensions for images; `null` for other files. |
| `hash` | SHA-256 checksum of the original bytes. |
| `scanStatus` | Malware scan result: `pending`, `clean`, `infected`, or `error`. |
| `metadataStatus` | Progress of metadata extraction: `pending`, `processing`, `complete`, or `error`. |
| `createdAt`, `createdBy` | When the revision was created, and the user who created it when one was recorded. `createdBy` is often `null`, for example for API-key uploads and uploads finalized in the background. |
| `label`, `note` | Optional team-facing text. See below. |
| `isCurrent` | Whether the stable link serves this revision right now. |
Revision bytes are never edited. To change a file, you add a revision.
In the dashboard, open a file and choose the **Version history** tab. The current revision is marked **Current**.
#### Labels and notes
A label is a short name, up to 120 characters, such as `Spring 2026 price list`. A note is longer context, up to 1,000 characters, such as who approved the change. Both are visible to workspace members only; they are never sent with delivered files.
Set either one to an empty string to clear it. In the dashboard, select a revision's name to rename it.
```bash title="curl"
curl -X PATCH "https://api.steadylink.io/api/assets/$ASSET_ID/versions/4" \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "label": "Spring 2026 price list", "note": "Approved by finance on 3 March." }'
```
```ts title="JavaScript"
await steadylink.updateVersion(assetId, 4, {
label: "Spring 2026 price list",
note: "Approved by finance on 3 March.",
});
```
```python title="Python"
client.request("PATCH", f"/api/assets/{asset_id}/versions/4", {
"label": "Spring 2026 price list",
"note": "Approved by finance on 3 March.",
})
```
#### Make an older revision current
Restoring a revision (the API calls it promoting) moves the file's current pointer. It does not copy bytes or create a new revision, so the history keeps its numbering and the restored revision keeps its number.
- In the dashboard, open **Version history** and choose **Restore** on the revision.
- With the API, send `POST /api/assets/{asset_id}/versions/{version}/promote`.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/$ASSET_ID/versions/3/promote" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.rollback(assetId, 3);
```
```python title="Python"
client.request("POST", f"/api/assets/{asset_id}/versions/3/promote")
```
```bash title="CLI"
steadylink rollback 3f2a9c1e-7b4d-4e8a-9c61-2d5f0a8b7e13 3
```
Only a revision whose scan result is `clean` can be restored. Anything else returns `409 Only clean revisions can be promoted`. That includes revisions whose scan is still pending, so wait for the scan before restoring a revision you just uploaded.
The stable link serves the restored revision immediately from SteadyLink. Browsers and CDNs that cached the previous response may keep it for up to five minutes; see [Caching](https://steadylink.io/docs/concepts/stable-links#caching).
#### Schedule a change
You do not have to switch revisions by hand at the right moment. Any retained clean revision can be scheduled to become current at a future time, with an optional later rollback to the revision that was current before. The publish time must be at least ten seconds ahead and no more than one year ahead, and a scheduled job can be cancelled only while it is still queued. See [Replacement requests](https://steadylink.io/docs/guides/replacement-requests) for the workflow and [Requests API](https://steadylink.io/docs/reference/api/requests) for the routes.
#### Delete a revision
Deleting a revision permanently removes its original bytes and every cached image variant made from it. Its size stops counting toward your storage immediately. There is no undo.
| You delete | What happens |
| --- | --- |
| A revision that is not current | It is removed. Pinned links to it (`?v=N`) return `404`. The stable link is unaffected. |
| The current revision | Refused with `400` unless you pass `force=true`. With `force=true`, the highest-numbered remaining revision becomes current. |
| The only remaining revision | The file itself is deleted: its ID, its place in the bucket, and its stable link, which then returns `404`. Because it is also the current revision, this needs `force=true`. |
```bash title="curl"
curl -X DELETE "https://api.steadylink.io/api/assets/$ASSET_ID/versions/2" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.deleteVersion(assetId, 2);
// Deleting the current revision needs force:
await steadylink.deleteVersion(assetId, 5, { force: true });
```
```python title="Python"
client.request("DELETE", f"/api/assets/{asset_id}/versions/2")
```
**Warning: Revision numbers can be reused.** A new revision is numbered one higher than the highest revision that still exists. If you delete the newest revision, say 5, the next upload becomes revision 5 again. A pinned `?v=5` link then serves the new bytes, and caches that stored the old revision 5 as immutable can keep serving it for up to a year. Delete older revisions freely, but avoid deleting the newest one if anyone may hold a pinned link to it.
With `force=true`, "highest-numbered remaining revision" is chosen by number only. If that revision is not clean, the stable link returns `423` or `404` until you restore a clean one, so check the history before force-deleting the current revision.
#### Retention
Each plan lists a revision history window: 30 days on Free, 90 days on Personal, and long-term on Pro and Business. Automatic expiry of old revisions is **not active yet**. Today every revision stays until someone deletes it, and every retained revision counts toward storage. See [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits).
If storage matters to you, delete revisions you no longer need. [`GET /api/analytics/storage`](https://steadylink.io/docs/reference/api/analytics) breaks storage down from each bucket to each file, with the current revision's size next to the total retained size.
#### Next steps
- [Stable links](https://steadylink.io/docs/concepts/stable-links): How the stable link follows the current revision, and how pinned links behave.
- [Revisions API](https://steadylink.io/docs/reference/api/revisions): List, label, promote, and delete revisions programmatically.
- [Upload and replace files](https://steadylink.io/docs/guides/upload-and-replace): Add new revisions from the dashboard.
### Upload lifecycle
Source: https://steadylink.io/docs/concepts/uploads
An upload is not finished when the bytes arrive. SteadyLink checks the size, the real file type, your bucket's rules, and the file's contents for malware before the file becomes a revision anyone can download. This page walks through each stage so you can build reliable upload code, read upload status correctly, and explain to a user why a file did not go through.
Upload sessions start as created, move through committing and scanning, and end ready, blocked, or failed. A session can also be cancelled before it finishes.
#### The three steps
Every API upload follows the same three steps. The dashboard, the [TypeScript SDK](https://steadylink.io/docs/reference/sdk)'s `upload()`, and the [CLI](https://steadylink.io/docs/reference/cli) run them for you.
**Create an upload batch**
`POST /api/upload-batches` with the destination bucket and up to **100 files**. For each file you send its name, an optional folder path, and its exact size in bytes. SteadyLink checks each file against your plan's size limit, reserves storage for it, and returns one upload session per file with a presigned `uploadUrl`.
**Send the bytes**
`PUT` the raw file to the session's `uploadUrl` with `Content-Type: application/octet-stream`. This request goes straight to object storage, not to the SteadyLink API, so it does not use your API key and does not count as an API operation.
**Complete the session**
`POST /api/upload-sessions/{session_id}/complete`. SteadyLink answers `202 Accepted` right away and finalizes the file in the background. Poll `GET /api/upload-batches/{batch_id}` or listen on `GET /api/events/stream` until the session reaches a terminal state.
The [Uploads API reference](https://steadylink.io/docs/reference/api/uploads) has full request and response examples. The [browser uploads guide](https://steadylink.io/docs/guides/browser-uploads) shows how to run the second step from a user's browser without exposing your key.
#### Session states
| `status` | Meaning | Terminal |
| --- | --- | --- |
| `created` | The session exists and is waiting for bytes and completion. | No |
| `uploaded` | Bytes were streamed through the API instead of the presigned URL. | No |
| `committing` | Completion was requested. SteadyLink is checking and storing the file. | No |
| `scanning` | The file is stored as a revision and the malware scan is running. | No |
| `ready` | The file passed every check and is available under its visibility settings. | Yes |
| `blocked` | A bucket policy or the malware scanner rejected the file. Nothing is delivered. | Yes |
| `failed` | Finalization could not complete. The `error` object says why. | Yes |
| `cancelled` | The session was cancelled before it finished. | Yes |
A batch's own `status` is `active` while any session is still moving, then `complete` if every file is `ready`, or `partial` if any file ended `blocked` or `failed`.
#### What is checked, and where
Checks run in this order. The first one that fails stops the file.
##### When the batch is created
- **Size is required.** A file without `size` is rejected with `422 upload_size_required`. SteadyLink needs the size up front to enforce limits and reserve storage.
- **Plan file size limit.** A file larger than your plan allows is rejected with `413 upload_size_limit`. Uploads made with an API key are also held to the API plan's upload limit, whichever is lower. See [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits#plan-limits).
- **Storage.** Each file's size is reserved against your storage allowance while the session is open. If the workspace would go over, the batch is refused. Reservations are released when a session is cancelled, expires, fails, or is blocked.
- **Batch size.** More than 100 files in one batch is rejected with `422`. Split larger sets into several batches.
##### During finalization
- **The bytes arrived.** If nothing was uploaded to the presigned URL, or the temporary bytes expired, the session ends `failed` with `upload_missing`.
- **Exact size.** The stored bytes must match the declared `size`. A different size ends `failed` with `upload_size_mismatch`; a file over the limit ends `failed` with `upload_too_large`.
- **Real file type.** SteadyLink reads the bytes to detect what the file really is, and stores and serves the detected type. When a request declares a specific type that belongs to a different family than the detected one (for example a "PNG" that is really a PDF), the file is rejected with `415 MIME mismatch`. A bucket can turn this check off with its `block_mime_mismatch` setting.
- **Bucket policy.** A bucket can set a maximum upload size in MB (`max_upload_mb`), allowed MIME type prefixes such as `image/` (`allowed_mime_prefixes`), and allowed filename extensions such as `pdf` (`allowed_extensions`). The type prefix is compared with the detected type, not the name. A file that breaks any of these ends `blocked` with the error code `policy` and a message such as `File type not allowed by bucket policy`. See [Buckets and folders](https://steadylink.io/docs/guides/buckets-and-folders).
##### After the revision is stored
- **Malware scan.** Every revision is scanned with ClamAV. A clean result moves the session to `ready`. A detection moves it to `blocked` with `malware_detected`, and the infected revision can never be delivered or restored.
If the file was a replacement, the new revision becomes current as soon as it is stored, before the scan finishes. While the scan runs, the file's stable link answers `423 Locked` rather than serve unscanned bytes. See [The scan window after a replacement](https://steadylink.io/docs/concepts/stable-links#the-scan-window-after-a-replacement).
#### Time limits
| Limit | Value | What happens when it passes |
| --- | --- | --- |
| Presigned `uploadUrl` | 15 minutes from batch creation | Object storage refuses the `PUT`. Create a new batch. |
| Upload session (`expiresAt`) | Shown on each session; 24 hours by default | Completing returns `410 Upload session expired`, the session ends `failed` with `upload_expired`, and its storage reservation is released. |
Start the `PUT` promptly after creating the batch. The 15-minute window covers starting the request; for large files on slow connections, create the batch just before you upload rather than queuing many batches ahead of time.
#### Completing safely more than once
Completion is designed to be retried. Send an `Idempotency-Key` header with a value that is stable for the session, such as `upload-` followed by the session ID:
```bash title="Complete an upload"
curl -X POST "https://api.steadylink.io/api/upload-sessions/$SESSION_ID/complete" \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Idempotency-Key: upload-$SESSION_ID"
```
- If the session already reached a terminal state, the call returns that state again instead of doing anything.
- If the session is still `committing`, the call returns `409 Upload is already committing`. Treat this as "in progress" and poll the batch.
- If the session was completed earlier with a different idempotency key, the call returns `409 Idempotency key mismatch`.
So a client that times out can always repeat the same completion call. It never creates a second revision.
If background finalization hits a temporary problem, SteadyLink retries it on its own, with the session showing `committing` and the error code `commit_retrying`. After repeated failures the session ends `failed` with `commit_failed` and its storage reservation is released.
#### Cancelling
`DELETE /api/upload-sessions/{session_id}` cancels a session that has not finished and releases its storage reservation. Sessions that already ended `ready`, `blocked`, or `failed` cannot be cancelled and return `409`.
#### Next steps
- [Upload and replace files](https://steadylink.io/docs/guides/upload-and-replace): The same lifecycle from the dashboard.
- [Uploads API](https://steadylink.io/docs/reference/api/uploads): Requests, responses, and status streaming.
- [Security overview](https://steadylink.io/docs/security): Scanning, upload policies, and clean delivery.
### Workspaces and roles
Source: https://steadylink.io/docs/concepts/workspaces-and-roles
A workspace is the boundary for everything in SteadyLink: buckets and files, members, API keys, usage, billing, and settings. This page is for workspace owners and admins deciding who gets which role, and for developers deciding which credential an integration should use.
#### Workspaces
When you sign up, you get a workspace and you are its **owner**. You can create more workspaces and be invited to other people's. Nothing is shared between workspaces: a file, a key, or a member in one workspace has no access to another.
Each workspace has:
- **Buckets and files**, and the [usage](https://steadylink.io/docs/concepts/usage-and-limits) they generate.
- **Members**, each with one role.
- **API keys**, each bound to this workspace only.
- **A plan**, which sets storage, delivery, seats, and API limits.
- **Defaults for new files**: whether new files start public or private, and whether delivery requires a clean malware scan. Change them in **Settings > Workspace**.
#### Roles
Every member has exactly one role. Roles are a fixed set of permissions:
| Permission | Viewer | Member | Admin | Owner |
| --- | --- | --- | --- | --- |
| See workspace details and members (`workspace:read`) | Yes | Yes | Yes | Yes |
| See files, folders, and revisions (`assets:read`) | Yes | Yes | Yes | Yes |
| See analytics (`analytics:read`) | Yes | Yes | Yes | Yes |
| Upload, replace, move, share, and delete files (`assets:write`) | | Yes | Yes | Yes |
| Run file conversions (`conversions:run`) | | Yes | Yes | Yes |
| Change settings, members, invitations, API keys, and platform controls (`workspace:admin`) | | | Yes | Yes |
| Manage billing (`billing:manage`) | | | Yes | Yes |
| Usage overage policy, ownership transfer, workspace deletion (`ownership:manage`) | | | | Yes |
A request your role does not allow returns `403` with the code `insufficient_workspace_permission`, the permission that was missing, and your role.
##### Rules for admins and owners
- Admins can invite, change, and remove members and viewers. Only the owner can invite an admin, change an admin's role, or remove an admin.
- Every workspace has exactly one owner. The owner cannot be removed or demoted directly; transfer ownership to another member first. After a transfer, the previous owner becomes an admin.
- Only the owner can delete the workspace, and only from a signed-in session, by typing the workspace name to confirm.
##### Invitations
Admins and owners invite people from **Settings > Members > Invite member**, choosing a role. An invitation expires after 7 days by default and can be set to anywhere from 1 to 30 days through the API. Inviting the same email again revokes the earlier pending invitation. Pending invitations count toward your plan's seat limit along with current members, so revoke invitations nobody will accept.
**Note.** If your workspace requires email verification, a member whose email address is not verified can read but cannot change anything until they verify it.
#### Three kinds of credential
People sign in; integrations use keys. SteadyLink has three kinds of credential and each one answers "who is acting" differently.
| | User session | Workspace API key | S3 app key |
| --- | --- | --- | --- |
| Who it acts as | A person | The workspace, not a person | The person who created it |
| Created by | Signing in | An owner or admin in **Dashboard > Developers** | Any member, for themselves, from a signed-in session (viewers can create read-only keys only) |
| How it is sent | `Authorization: Bearer ...` | `X-API-Key: slk_...` | AWS Signature Version 4 |
| Permissions | The person's role | The key's scopes | The person's current role, optionally read-only and limited to one bucket |
| Workspaces | Every workspace the person belongs to | Exactly one | Exactly one |
| Used for | The dashboard | Servers, CI, the SDKs, the CLI, the GitHub Action | S3 tools such as rclone, restic, Cyberduck, the AWS CLI |
Send either a bearer token or an API key, never both. A request with both headers is rejected with `400`.
##### Workspace API keys
An API key belongs to the workspace. It keeps working when the person who created it leaves, and it can do only what its scopes allow:
| Scope | Allows |
| --- | --- |
| `assets:read` | Reading buckets, files, revisions, upload status, and signed-link records |
| `assets:write` | Uploads, replacements, revisions, visibility, signed links, and other file changes |
| `analytics:read` | Traffic, storage, and activity analytics |
| `workspace:read` | Workspace details and members |
| `workspace:admin` | Platform controls such as delivery policy, webhooks, delivery domains, and SSO configuration |
Some actions always need a signed-in person, whatever scopes a key has: creating, listing, and revoking API keys; managing members and invitations; changing workspace settings; creating S3 app keys; and two-step verification. A key cannot create another key.
Each plan limits how many API keys can be active at once. See [API key safety](https://steadylink.io/docs/security/api-keys) for scopes, expiry, and rotation.
##### S3 app keys
An S3 app key lets S3-compatible tools reach your buckets. It acts as the person who made it, and SteadyLink re-checks that person's membership and role on every request. Remove the member, or lower them to viewer, and their write access through S3 stops immediately. A key can also be made read-only or limited to one bucket. Each person can have up to 20 active S3 app keys per workspace. See the [S3-compatible API](https://steadylink.io/docs/reference/s3).
#### Choosing the workspace for a request
An API key is bound to one workspace, so requests made with it need nothing more. If you also send `X-Workspace-Id` with a key, it must match the key's workspace or the request is refused with `403`.
A user session can belong to several workspaces. Send the `X-Workspace-Id` header with the workspace ID to choose one:
```bash title="Select a workspace with a user session"
curl https://api.steadylink.io/api/workspaces/current \
-H "Authorization: Bearer $STEADYLINK_ACCESS_TOKEN" \
-H "X-Workspace-Id: 9d1c7e40-2b6a-4f3e-8a15-6c0e2f9b4d71"
```
If the person belongs to exactly one workspace, the header can be omitted. If they belong to more than one and the header is missing, the request returns `400` with the code `workspace_required`.
#### Single sign-on and roles
On plans with single sign-on, a workspace can require members to sign in through its identity provider. People who sign in through SSO for the first time join the workspace as members. SSO enforcement applies to user sessions only; API keys and S3 app keys keep working. See [Single sign-on](https://steadylink.io/docs/security/single-sign-on).
#### Next steps
- [API key safety](https://steadylink.io/docs/security/api-keys): Least privilege, expiry, rotation, and revocation.
- [Authentication](https://steadylink.io/docs/reference/api/authentication): Headers, scopes, and error codes for every credential type.
- [Workspaces API](https://steadylink.io/docs/reference/api/workspaces): Settings, members, invitations, and keys over the API.
### Usage and limits
Source: https://steadylink.io/docs/concepts/usage-and-limits
SteadyLink meters a few resources separately, and each limit stops only the work that uses it. Running out of delivery does not stop uploads; running out of storage does not stop delivery. This page is for workspace owners and admins who want to predict usage, understand a limit error, and choose what happens when a limit is reached.
#### What is metered
| Resource | Unit | Resets |
| --- | --- | --- |
| Storage | Bytes kept across every retained revision | Never; it is a running total |
| Delivery | Bytes in successful file responses | Every usage period |
| Management API operations | One per request made with an API key | Every usage period |
| Conversion credits | Weighted credits per conversion job | Every usage period |
The usage period follows your subscription's billing period on monthly paid plans. On the free plan and on annual plans it is the calendar month (UTC).
Conversions have their own credit model; see [File conversions](https://steadylink.io/docs/guides/file-conversions).
##### Storage
Storage counts:
- The current revision of every file.
- Every older revision you have kept. Revisions are not removed automatically yet, so history grows until you delete it. See [Revisions](https://steadylink.io/docs/concepts/revisions#retention).
- Space reserved for uploads in progress. Each open upload session holds its declared size until it finishes, fails, expires, or is cancelled.
Deleting a revision releases its bytes immediately. Deleting a file or a bucket releases everything in it.
##### Delivery
Delivery counts successful (`2xx`) responses from stable and pinned links. Each response counts as its actual size or **64 KB, whichever is larger**, so a 3 KB icon counts as 64 KB and a 2 MB photo counts as 2 MB. The minimum keeps very small, very frequent requests from being free to serve.
These do not count toward delivery:
- `HEAD` requests.
- Failed and refused responses: `403`, `404`, `423`, and other errors.
- Previews in the dashboard.
Analytics always show the real number of bytes sent, without the 64 KB minimum, so analytics totals and metered delivery can differ for workspaces with many small files.
##### Management API operations
Every request authenticated with a workspace API key counts as one operation, whatever it does. Requests made by signed-in people in the dashboard do not count, and neither do file downloads from delivery links or byte uploads sent to presigned URLs.
API keys are also rate-limited per minute (see the table below). Going over a rate limit returns `429` for a short time and does not use your monthly allowance. See [Errors](https://steadylink.io/docs/reference/errors).
#### Thresholds
SteadyLink tracks each resource against your plan's allowance for the period:
- **50%**: a notification lets the workspace know. Nothing is restricted.
- **80%**: a second notification, so an owner can upgrade or plan ahead.
- **100%**: the workspace's limit rule applies (below).
Each threshold notifies once per resource per period.
#### What happens at 100%
The owner chooses one rule for the workspace:
| Rule | What happens to work that would go over the limit |
| --- | --- |
| Stop (`hard_stop`) | Refused until the period resets or the plan changes. |
| Throttle (`throttle`) | Refused with the code `usage_throttled` and a `Retry-After` header, so clients back off and try again later. |
| Require an upgrade (`require_upgrade`) | Refused with an upgrade suggestion until the plan changes. |
| Continue with paid usage (`billed_overage`) | Allowed and billed as overage, up to the owner's monthly spending cap. Past the cap, the cap rule (stop or throttle) applies. |
New workspaces on Free and Personal start with **Require an upgrade**; Pro and Business start with **Stop**. Only the owner can change the rule.
Paid overage is available on paid plans billed monthly; it is not available on Free or on annual billing. It requires the owner's explicit consent and a monthly spending cap, and it applies to storage, delivery, and conversions. Management API operations never go into overage; at 100% they stop, throttle, or require an upgrade.
##### Errors at the limit
Refused work returns a structured error your code can branch on:
```json title="Response" status="429 Too Many Requests"
{
"detail": {
"code": "usage_limit_exceeded",
"resource": "delivery_bytes",
"usage": 53687091200,
"reserved": 0,
"requested": 65536,
"limit": 53687091200,
"resetAt": "2026-11-01T00:00:00Z",
"behavior": "require_upgrade",
"upgrade": { "recommendedPlan": "personal", "url": "/dashboard/settings?section=plans" }
}
}
```
- Delivery and API limits return `429` with `Retry-After: 3600`.
- Storage limits return `413`. A new upload that would not fit is refused with `usage_limit_exceeded` or `storage_quota_exceeded`. Existing files keep being delivered.
- Branch on `code` and `resource`, not on message text.
#### Plan limits
Paid limits apply while the subscription is active. If a paid subscription lapses, the workspace falls back to Free limits.
| | Free | Personal | Pro | Business |
| --- | --- | --- | --- | --- |
| Storage | 10 GB | 50 GB | 100 GB | 500 GB |
| Delivery per period | 50 GB | 100 GB | 1 TB | 3 TB |
| Members | 1 | 1 | 5 | 20 |
| Largest file | 100 MB | 250 MB | 500 MB | 2 GB |
| Management API operations per period | 50,000 | 250,000 | 2,000,000 | 25,000,000 |
| API requests per minute (read / write) | 120 / 30 | 300 / 75 | 1,200 / 300 | 6,000 / 1,500 |
| Active API keys | 1 | 2 | 10 | 50 |
| Revision history window | 30 days | 90 days | Long-term | Long-term |
| Single sign-on | No | No | No | Yes |
Enterprise limits are set by contract. Plan prices and the full feature comparison are on the [pricing page](https://steadylink.io/pricing).
The revision history window is the published policy for each plan; automatic expiry is not active yet, so revisions are kept until deleted on every plan.
#### Where to see usage
Owners and admins can open **Settings > Billing** in the dashboard to see each metered resource for the current period and the plan's feature limits. Over the API, `GET /api/usage/summary` returns the same figures, and [`GET /api/analytics/storage`](https://steadylink.io/docs/reference/api/analytics) breaks storage down by bucket and file.
#### Next steps
- [Revisions](https://steadylink.io/docs/concepts/revisions): Delete history you no longer need to free storage.
- [Errors](https://steadylink.io/docs/reference/errors): Every error code, including rate limits and usage limits.
- [Analytics API](https://steadylink.io/docs/reference/api/analytics): Delivery, storage, and activity figures.
### Reference
Source: https://steadylink.io/docs/reference
Use these pages when you know what you want to do and need the exact route, parameter, flag, or error code. For a guided path, start with the [Quickstart](https://steadylink.io/docs/quickstart).
#### APIs
- [REST API](https://steadylink.io/docs/reference/api): Base URL, authentication, conventions, and every resource: buckets, files, uploads, revisions, delivery, and more.
- [S3-compatible API](https://steadylink.io/docs/reference/s3): Connect rclone, restic, Cyberduck, the AWS CLI, and NAS backup apps.
- [Webhooks](https://steadylink.io/docs/reference/webhooks): Event types, payloads, signatures, and retries.
- [Errors](https://steadylink.io/docs/reference/errors): Status codes, error bodies, rate-limit headers, and when to retry.
#### Libraries and tools
- [TypeScript SDK](https://steadylink.io/docs/reference/sdk): Every method in `@steadylink/sdk`, with options and return types.
- [Python SDK](https://steadylink.io/docs/reference/python-sdk): The synchronous `steadylink` client for Python 3.10 and later.
- [CLI](https://steadylink.io/docs/reference/cli): Every command, flag, environment variable, and exit code.
- [GitHub Action](https://steadylink.io/docs/reference/github-action): Inputs and outputs for `SteadyLink-io/upload-action`.
### REST API: REST API
Source: https://steadylink.io/docs/reference/api
The SteadyLink REST API is the same API the dashboard uses. Anything you can do to buckets, files, uploads, revisions, and links in the dashboard, you can do from a server, a script, or a CI job. This page covers the rules every endpoint shares. Read it once, make the first request below, then jump to the resource you need.
#### Base URL
All management routes live under one origin and the `/api` prefix:
```text
https://api.steadylink.io/api
```
Files are delivered from a separate origin that never needs an API key:
```text
https://cdn.steadylink.io/a/{asset_id}
```
Keep the two apart in your code. The API origin is for managing content with a credential. The delivery origin is what you put in ` ` tags, emails, and QR codes. See [Delivery and signed links](https://steadylink.io/docs/reference/api/delivery).
#### Authentication
Server integrations send a workspace API key in the `X-API-Key` header:
```bash
curl https://api.steadylink.io/api/assets/ \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
| Header | When to send it |
| --- | --- |
| `X-API-Key` | Every request from a server integration. The key is bound to one workspace and carries its own scopes. |
| `Authorization: Bearer ` | Requests made with a signed-in user session, such as the dashboard. Not for integrations. |
| `X-Workspace-Id` | Selects a workspace for a user session that belongs to more than one workspace. With an API key it is optional; if you send it, it must match the key's workspace or the request returns `403`. |
Send exactly one credential. A request that carries both `Authorization` and `X-API-Key` is rejected with `400` and the message `Use either bearer authentication or an API key, not both`. A user session that belongs to several workspaces and omits `X-Workspace-Id` on a workspace-level route gets `400` with the code `workspace_required`.
Each key has scopes, and the scope a request needs is decided by its path and method: reads under `/api/assets` need `assets:read`, writes need `assets:write`, and so on. [Authentication](https://steadylink.io/docs/reference/api/authentication) lists every scope, plan limit, and rate-limit header.
#### Make your first request
List the buckets in your workspace. Create a key first in **Dashboard > Developers > New API key** with the `assets:read` scope, and export it as `STEADYLINK_API_KEY`.
```bash title="curl"
curl "https://api.steadylink.io/api/assets/?limit=20" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
import { SteadyLink } from "@steadylink/sdk";
const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! });
const { items, nextCursor } = await steadylink.listBuckets(20);
for (const bucket of items) console.log(bucket.id, bucket.name);
```
```python title="Python"
import os
from steadylink import SteadyLink
client = SteadyLink(api_key=os.environ["STEADYLINK_API_KEY"])
page = client.list_buckets(limit=20)
for bucket in page["items"]:
print(bucket["id"], bucket["name"])
```
```json title="Response" status="200 OK"
{
"items": [
{
"id": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"name": "Marketing",
"slug": "marketing",
"kind": "bucket",
"createdAt": "2026-08-17T18:31:00.412093",
"isPrivate": false
}
],
"nextCursor": "2026-08-17T18:31:00.412093"
}
```
If you get `401`, the key is missing, mistyped, expired, or revoked. If you get `403` with `insufficient_scope`, the key exists but lacks the scope named in `message.required`.
Without an SDK, any HTTP client works. This is the same request with `fetch` and with `requests`:
```ts title="JavaScript"
const response = await fetch("https://api.steadylink.io/api/assets/?limit=20", {
headers: { "X-API-Key": process.env.STEADYLINK_API_KEY! },
});
const body = await response.json();
if (!response.ok) {
throw new Error(`${response.status} ${JSON.stringify(body.message)} (request ${body.requestId})`);
}
const { items, nextCursor } = body;
```
```python title="Python"
import os
import requests
response = requests.get(
"https://api.steadylink.io/api/assets/",
params={"limit": 20},
headers={"X-API-Key": os.environ["STEADYLINK_API_KEY"]},
timeout=30,
)
body = response.json()
if not response.ok:
raise RuntimeError(f"{response.status_code} {body['message']} (request {body['requestId']})")
items = body["items"]
```
#### Requests and responses
**JSON bodies use camelCase.** Request bodies and response fields are camelCase: `bucketId`, `contentType`, `expiresInDays`, `objectAssetId`, `nextCursor`. Send bodies with `Content-Type: application/json`.
**Many inputs are query parameters, even on `POST`.** Several file operations take their inputs in the query string, and those names are snake_case because they mirror the server's parameters: `from_key`, `to_key`, `upload_temp_key`, `content_type`, `bucket_id`. Each operation's reference lists exactly where every parameter goes. When in doubt, URL-encode keys and paths: a file key such as `campaign/launch/hero image.webp` must be sent as `campaign%2Flaunch%2Fhero%20image.webp`.
**IDs are UUIDs.** Buckets, files, upload sessions, revisions, and grants are identified by UUID strings. A file's UUID is its *asset ID*, the value in its delivery URL.
**Timestamps are UTC without an offset.** Fields such as `createdAt` and `expiresAt` look like `2026-08-17T18:31:00.412093`. Treat them as UTC.
**Successful writes return small confirmations.** Many writes return a flag such as `{"deleted": true}` or `{"renamed": true}` rather than the updated resource. Read the resource again if you need its new state.
#### Pagination
List endpoints that can grow without bound use cursor pagination. Pass `limit` and, for the next page, the `nextCursor` value from the previous response as `cursor`.
| Endpoint | `limit` | Cursor behavior |
| --- | --- | --- |
| [List buckets](https://steadylink.io/docs/reference/api/buckets#list-buckets) | 1 to 100, default 20 | `nextCursor` is set whenever the page has items, including the last page. Stop when a page returns fewer items than `limit` or no items. |
| [List every file in the workspace](https://steadylink.io/docs/reference/api/files#list-every-file-in-the-workspace) | 1 to 1000, default 1000 | `nextCursor` is `null` on the last page. An invalid cursor returns `422`. |
Other lists return everything in one response: a folder listing, a file's revisions, and a file's signed links (the 200 most recent).
```ts title="Read every bucket"
let cursor: string | undefined;
const buckets = [];
while (true) {
const page = await steadylink.listBuckets(100, cursor);
buckets.push(...page.items);
if (page.items.length < 100 || !page.nextCursor) break;
cursor = page.nextCursor;
}
```
#### Idempotency
[Completing an upload session](https://steadylink.io/docs/reference/api/uploads#complete-an-upload-session) is the one operation with an explicit `Idempotency-Key` contract. Send a key that is unique to the session, such as `complete-{session_id}`, and reuse the same key when you retry. A different key for a session that already recorded one returns `409`.
The TypeScript and Python clients only retry a write automatically when it carries an `Idempotency-Key`. Reads (`GET`, `HEAD`, `OPTIONS`) are retried on `429`, `502`, `503`, and `504`.
#### Request IDs
Every response carries an `X-Request-Id` header. You can send your own value (ASCII, at most 128 characters) to correlate logs across systems. Anything else is replaced with a generated UUID. Error bodies repeat the same value as `requestId`:
```json title="Error response" status="403 Forbidden"
{
"code": 403,
"message": {
"code": "insufficient_scope",
"required": "assets:write"
},
"requestId": "f3b6a0c2-91d4-4e7b-8a5c-2d9e1f0b7c34"
}
```
`message` is a string for simple errors and an object with a machine-readable `code` for errors you are expected to handle. Request bodies that fail validation return `400` with the message `Validation error`. Include the request ID when you contact support. Never include the API key or a signed token. The full list of codes is in [Errors](https://steadylink.io/docs/reference/errors).
#### Rate limits
API-key requests are limited per workspace, not per key: a short burst window, a per-minute read or write window, a monthly operation allowance, and a separate allowance for expensive operations such as uploads, replacements, and signed links. Successful API-key responses include `RateLimit`, `RateLimit-Policy`, and `X-RateLimit-*` headers. A limited request returns `429` with `Retry-After` in seconds. Plan values and header formats are in [Authentication](https://steadylink.io/docs/reference/api/authentication#rate-limits).
#### Resources
- [Authentication](https://steadylink.io/docs/reference/api/authentication): API keys, scopes, expiry, plan limits, rate-limit headers, and revocation.
- [Buckets](https://steadylink.io/docs/reference/api/buckets): List, create, configure, and delete buckets.
- [Files and folders](https://steadylink.io/docs/reference/api/files): List, search, inspect, rename, move, replace, change visibility, and delete files.
- [Uploads](https://steadylink.io/docs/reference/api/uploads): Upload batches, presigned byte uploads, completion, status, and live events.
- [Revisions](https://steadylink.io/docs/reference/api/revisions): List, label, promote, and delete the revisions behind one stable link.
- [Delivery and signed links](https://steadylink.io/docs/reference/api/delivery): The public delivery route, image parameters, caching, and revocable signed links.
- [Analytics](https://steadylink.io/docs/reference/api/analytics): Traffic totals, time series, top files, traffic sources, activity, and storage.
- [Workspaces](https://steadylink.io/docs/reference/api/workspaces): Workspace settings, members, invitations, and API entitlement.
- [Conversions](https://steadylink.io/docs/reference/api/conversions): Convert uploads or stored files and save the results to a bucket.
- [Requests and scheduling](https://steadylink.io/docs/reference/api/requests): Replacement requests, request forms, scheduled revisions, website migration, and notification preferences.
- [Sharing](https://steadylink.io/docs/reference/api/sharing): Upload widgets, collections, and portals.
- [Platform controls](https://steadylink.io/docs/reference/api/platform): Delivery policy, focal points, bulk migrations, delivery domains, encryption, and single sign-on.
#### Next steps
- [Create a scoped key](https://steadylink.io/docs/reference/api/authentication#create-an-api-key): Pick the narrowest scopes your integration needs.
- [Upload your first file](https://steadylink.io/docs/reference/api/uploads): Create a batch, send bytes, complete, and get the stable link.
- [Use an SDK](https://steadylink.io/docs/reference/sdk): `@steadylink/sdk` wraps batching, retries, and uploads for you.
### REST API: Authentication
Source: https://steadylink.io/docs/reference/api/authentication
Use this page to give an integration exactly the access it needs. You will create a workspace API key, choose its scopes and expiry, learn how much traffic your plan allows, read the rate-limit headers on each response, and revoke the key when it is no longer needed.
#### How requests authenticate
SteadyLink accepts two kinds of credentials. Integrations should always use an API key.
| Credential | Header | Who uses it | Workspace |
| --- | --- | --- | --- |
| Workspace API key | `X-API-Key: slk_...` | Servers, scripts, CI jobs, the SDKs, the CLI, and the GitHub Action | Fixed: a key belongs to exactly one workspace |
| User session token | `Authorization: Bearer ` | The dashboard, on behalf of a signed-in person | Chosen with `X-Workspace-Id` when the person belongs to several workspaces |
Send one or the other. A request with both headers returns `400`. With an API key, `X-Workspace-Id` is optional and must match the key's workspace if you send it; a different workspace returns `403`.
An API key acts on behalf of the workspace, not a person. It is limited by its scopes, not by the role of the person who created it. A session token acts as the person and is limited by their [workspace role](https://steadylink.io/docs/concepts/workspaces-and-roles).
When a key is checked, SteadyLink does the following, in order, and stops at the first failure:
1. Looks up the key. Unknown keys return `401 Invalid API key`.
2. Rejects revoked and expired keys with `401 API key expired or revoked`.
3. Checks that the workspace's plan allows API access. Paused access returns `403` with `subscription_inactive` or `api_access_disabled`.
4. Applies the workspace's rate limits. A limit returns `429`.
5. Checks the route's required scope. A missing scope returns `403` with `insufficient_scope`.
6. Runs the route inside the key's workspace. Resources in other workspaces return `403` or `404`.
#### Create an API key
Most people create keys in the dashboard: open **Dashboard > Developers**, choose **New API key**, name it after the system that will use it, and select its scopes. The raw key is shown once. SteadyLink stores only a hash, so a lost key cannot be recovered; revoke it and create another.
Keys start with `slk_`. Store the value in your server's secret manager or environment, for example as `STEADYLINK_API_KEY`.
**Warning.**
An API key is a server credential. Do not ship it to a browser, a mobile app, a public repository, or a build artifact. For uploads from a browser, create the upload session on your server and send only the presigned `uploadUrl` to the browser. See [Browser uploads](https://steadylink.io/docs/guides/browser-uploads).
Key management routes act on behalf of a person. They require a signed-in session of a workspace owner or admin and reject API keys, even keys that hold `workspace:admin`. An integration cannot mint or revoke keys for itself.
##### Create an API key
**POST /api/workspaces/current/api-keys**
**Body**
- `name` (string): Label shown in the dashboard, 1 to 200 characters. Name it after the integration, such as `production uploader`.
- `scopes` (string[]): One or more of the [scopes](#scopes) below. Duplicates are ignored. An empty list or an unknown scope returns `422` with the code `invalid_scopes` and the list of allowed values.
- `expiresInDays` (integer): Days until the key stops working, 1 to 365. Omit it for a key that does not expire.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/workspaces/current/api-keys" \
-H "Authorization: Bearer $STEADYLINK_SESSION_TOKEN" \
-H "X-Workspace-Id: 0d9b3f6e-2a71-4c58-b8e4-5f1a7c3d9e20" \
-H "Content-Type: application/json" \
-d '{
"name": "production uploader",
"scopes": ["assets:read", "assets:write"],
"expiresInDays": 90
}'
```
```json title="Response" status="201 Created"
{
"id": "6a2f9d4c-1e8b-4f37-a5c0-9b3d7e2f1a68",
"name": "production uploader",
"scopes": ["assets:read", "assets:write"],
"createdAt": "2026-10-08T14:22:31.482913",
"lastUsedAt": null,
"expiresAt": "2027-01-06T14:22:31.482913",
"revokedAt": null,
"key": "slk_9QxV2mT7c4LrW8pZ1nKe5bYh3sJa6dGf0uHo"
}
```
| Status | Code | Meaning |
| --- | --- | --- |
| `403` | `insufficient_workspace_permission` | The person is not an owner or admin, or the request used an API key. |
| `403` | `subscription_inactive`, `api_access_disabled` | The workspace cannot use the API right now. See [Plan access](#plan-access). |
| `409` | `api_key_limit_reached` | The workspace already has as many active keys as its plan allows. Revoke one first. |
| `422` | `invalid_scopes` | The scope list is empty or contains a value that is not a key scope. |
##### List API keys
**GET /api/workspaces/current/api-keys**
Returns every key in the workspace, newest first, including revoked and expired keys. Raw key values are never returned after creation.
```json title="Response" status="200 OK"
{
"items": [
{
"id": "6a2f9d4c-1e8b-4f37-a5c0-9b3d7e2f1a68",
"name": "production uploader",
"scopes": ["assets:read", "assets:write"],
"createdAt": "2026-10-08T14:22:31.482913",
"lastUsedAt": "2026-10-08T15:02:10.118204",
"expiresAt": "2027-01-06T14:22:31.482913",
"revokedAt": null
}
]
}
```
`lastUsedAt` is refreshed at most once every five minutes, so treat it as approximate. It is useful for finding keys that nothing uses anymore.
##### Revoke an API key
**DELETE /api/workspaces/current/api-keys/{key_id}**
Revocation takes effect on the next request: anything using the key starts receiving `401 API key expired or revoked`. The key stays in the list with `revokedAt` set, for your audit trail. A key ID from another workspace returns `404`.
```json title="Response" status="200 OK"
{ "revoked": true }
```
To rotate a key without downtime, create the new key, deploy it, confirm it works (its `lastUsedAt` appears), then revoke the old one. On the Free plan you can hold only one active key, so rotation means revoking first and deploying the new key immediately after.
#### Scopes
Pick the smallest set that covers what the integration does. A key that only reads delivery statistics should not be able to delete files.
| Scope | Dashboard label | Grants |
| --- | --- | --- |
| `assets:read` | Read files | Read buckets, folders, files, revisions, upload status, upload events, signed-link records, and conversion jobs. |
| `assets:write` | Change files | Create and change buckets, folders, files, uploads, replacements, revisions, signed links, collections, widgets, and conversions. |
| `analytics:read` | Read analytics | Read traffic, top files, traffic sources, storage, and activity analytics. |
| `workspace:read` | Read workspace | Read workspace details, members, invitations, platform settings, and the API entitlement. |
| `workspace:admin` | Manage workspace | Write access to `/api/platform/...` routes (delivery policy, focal points, migrations, domains, webhooks, SSO). Workspace settings, members, invitations, and API keys still require a signed-in owner or admin session. |
These five are the only scopes a key can hold. Role permissions such as billing management and ownership transfer are never available to keys.
##### How a route's scope is decided
The required scope comes from the request path and method, so you can predict it for any route:
| Path | `GET`, `HEAD`, `OPTIONS` | Other methods |
| --- | --- | --- |
| `/api/analytics/...` | `analytics:read` | `analytics:read` |
| `/api/operations/...` | `analytics:read` | `workspace:admin` |
| `/api/workspaces/...`, `/api/orgs/...`, `/api/platform/...` | `workspace:read` | `workspace:admin` |
| Everything else, including `/api/assets/...`, `/api/upload-batches`, and `/api/upload-sessions/...` | `assets:read` | `assets:write` |
A key without the required scope gets:
```json title="Response" status="403 Forbidden"
{
"code": 403,
"message": { "code": "insufficient_scope", "required": "assets:write" },
"requestId": "0b7e2c94-5a1d-4f63-9e8b-c2d4f6a8b013"
}
```
Some routes act on behalf of a person and reject keys regardless of scope, for example API-key management above and personal notification preferences.
#### Expiry
A key created with `expiresInDays` stops working at `expiresAt` and returns `401 API key expired or revoked`, exactly like a revoked key. Nothing warns the integration in advance, so record the expiry date where your team will see it, or check `expiresAt` from the key list on a schedule. Keys without an expiry work until revoked. Expired keys no longer count toward the plan's active-key limit.
#### Plan access
Every workspace can use the API, including Free. All limits below belong to the workspace and are shared by all of its keys.
| Plan | Active keys | Monthly operations | Reads per minute | Writes per minute | Burst per 10 s | Expensive ops per minute / day | Largest upload through the API |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Free | 1 | 50,000 | 120 | 30 | 30 | 10 / 250 | 100 MiB |
| Personal | 2 | 250,000 | 300 | 75 | 75 | 25 / 2,500 | 250 MiB |
| Pro | 10 | 2,000,000 | 1,200 | 300 | 300 | 120 / 25,000 | 500 MiB |
| Business | 50 | 25,000,000 | 6,000 | 1,500 | 1,250 | 600 / 250,000 | 2 GiB |
| Enterprise | 100 | 2,000,000,000 | 60,000 | 15,000 | 10,000 | 6,000 / 5,000,000 | 500 MiB |
Free includes one active, scoped API key and 50,000 monthly management operations, enough to build and test a real integration. Each authenticated API-key request counts as one management operation. Delivery through `cdn.steadylink.io` is not a management operation and is not limited by these numbers.
**Largest upload** is the lower of two limits: the API ceiling for the plan and the workspace's own upload limit. The values above are the effective result.
**Expensive operations** are writes whose path contains `/upload`, `/commit`, `/replace`, `/purge`, `/signed-url`, or `/retry`, plus every write under `/api/conversions`. Creating an upload batch, completing an upload session, finishing a replacement, and creating a signed link all count. These have their own per-minute and per-day ceilings so that one busy integration cannot exhaust scanning and storage capacity for the rest of the workspace.
**Paid plans need active billing.** Personal, Pro, Business, and Enterprise limits apply only while the subscription is active. If billing lapses or the billing period ends without renewal, API-key requests return `403` with the code `subscription_inactive`. Keys are not deleted: owners can still list and revoke them in the dashboard, and the keys work again as soon as billing is active. `api_access_disabled` means API access was turned off for the workspace by an administrative override; contact support.
Check the current state from code with [Get the API entitlement](https://steadylink.io/docs/reference/api/workspaces#get-the-api-entitlement):
```bash title="curl"
curl https://api.steadylink.io/api/workspaces/current/api-entitlement \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"enabled": true,
"reason": null,
"plan": "pro",
"billingStatus": "active",
"currentPeriodEnd": "2026-11-01T00:00:00",
"limits": {
"readRequestsPerMinute": 1200,
"writeRequestsPerMinute": 300,
"burstRequestsPer10Seconds": 300,
"expensiveOperationsPerMinute": 120,
"expensiveOperationsPerDay": 25000,
"requestsPerMonth": 2000000,
"activeApiKeys": 10,
"maxUploadBytes": 5368709120
},
"activeApiKeys": 2
}
```
`limits.maxUploadBytes` is the plan's API ceiling on its own. The effective largest upload is the smaller of that value and the workspace upload limit, as in the table above. This route needs `workspace:read`.
#### Rate limits
Limits are pooled by workspace. Five keys in one workspace share one allowance; creating more keys does not add capacity.
Each API-key request is checked against, in order:
1. The burst window: requests per 10 seconds, reads and writes together.
2. The per-minute window for its category. `GET`, `HEAD`, and `OPTIONS` are reads; everything else is a write.
3. The monthly operation allowance.
4. For expensive operations, the per-minute and per-day expensive allowances.
Successful API-key responses describe the per-minute window for the request's category:
| Header | Example | Meaning |
| --- | --- | --- |
| `RateLimit` | `limit=1200, remaining=1187, reset=42` | Limit, remaining requests, and seconds until the window resets. |
| `RateLimit-Policy` | `"steadylink";q=1200;w=60` | The quota and window length in seconds. |
| `X-RateLimit-Limit` | `1200` | Same limit, legacy header. |
| `X-RateLimit-Remaining` | `1187` | Same remaining count, legacy header. |
| `X-RateLimit-Reset` | `1791469380` | When the window resets, as a Unix timestamp in seconds. |
A request over any limit returns `429` with `Retry-After` in seconds and a code that names the limit:
```json title="Response" status="429 Too Many Requests"
{
"code": 429,
"message": {
"code": "api_write_limit_exceeded",
"message": "Rate limit exceeded",
"retryAfter": 18
},
"requestId": "4e1a8c63-0f2b-4d97-b5e8-71c3a9d0f246"
}
```
| Code | Limit reached |
| --- | --- |
| `api_burst_limit_exceeded` | Burst window (10 seconds) |
| `api_read_limit_exceeded` | Reads per minute |
| `api_write_limit_exceeded` | Writes per minute |
| `api_monthly_quota_exceeded` | Monthly operations |
| `api_expensive_operation_rate_exceeded` | Expensive operations per minute |
| `api_expensive_operation_limit_exceeded` | Expensive operations per day |
| `invalid_api_key_attempt_limit_exceeded` | Too many requests with unknown, expired, or revoked keys from one client address (30 per minute) |
Wait for `Retry-After` before retrying, and add jitter if several workers share a key. The SDKs already do this for reads and idempotent writes. A monthly or daily limit will not clear in seconds; reduce traffic or upgrade instead of retrying in a loop.
#### Next steps
- [REST API overview](https://steadylink.io/docs/reference/api): Conventions, pagination, idempotency, and request IDs.
- [Keeping API keys safe](https://steadylink.io/docs/security/api-keys): Storage, rotation, and what to do if a key leaks.
- [Errors](https://steadylink.io/docs/reference/errors): Every status and error code, and when to retry.
### REST API: Buckets
Source: https://steadylink.io/docs/reference/api/buckets
A bucket is a top-level container in a workspace, such as `Marketing` or `Product media`. Folders and files live inside it, and its settings decide the default visibility of new files and which uploads it accepts. Use these endpoints to find a bucket ID for uploads, create buckets from a provisioning script, apply an upload policy, or tear a bucket down.
Bucket routes live under `/api/assets` for historical reasons: in the API, a bucket is an asset of kind `bucket`, and a file is an asset of kind `object`. Bucket IDs and file asset IDs are both UUIDs, so keep track of which one you hold.
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"])`.
#### Endpoints
| Operation | Method and path | Scope |
| --- | --- | --- |
| [List buckets](#list-buckets) | `GET /api/assets/` | `assets:read` |
| [Create a bucket](#create-a-bucket) | `POST /api/assets/` | `assets:write` |
| [Get a bucket](#get-a-bucket) | `GET /api/assets/{bucket_id}` | `assets:read` |
| [Set a bucket's default privacy](#set-a-buckets-default-privacy) | `POST /api/assets/{bucket_id}/privacy` | `assets:write` |
| [Update bucket settings](#update-bucket-settings) | `POST /api/assets/{bucket_id}/settings` | `assets:write` |
| [Delete a bucket](#delete-a-bucket) | `DELETE /api/assets/{bucket_id}` | `assets:write` |
#### Operations
##### List buckets
**GET /api/assets/** (scope: `assets:read`)
Returns the workspace's buckets, newest first.
**Query parameters**
- `limit` (integer, default 20): Number of buckets per page. Values outside 1 to 100 are clamped into that range.
- `cursor` (string): The `nextCursor` from the previous page. It is the `createdAt` of the last bucket returned. A value that is not a timestamp is ignored and the first page is returned.
```bash title="curl"
curl "https://api.steadylink.io/api/assets/?limit=100" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const { items, nextCursor } = await steadylink.listBuckets(100);
// Or look one up by ID, slug, or name (case-insensitive)
const marketing = await steadylink.findBucket("marketing");
```
```python title="Python"
page = client.list_buckets(limit=100)
marketing = next(b for b in page["items"] if b.get("slug") == "marketing")
```
```bash title="CLI"
steadylink ls
```
```json title="Response" status="200 OK"
{
"items": [
{
"id": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"name": "Marketing",
"slug": "marketing",
"kind": "bucket",
"createdAt": "2026-08-17T18:31:00.412093",
"isPrivate": false
},
{
"id": "c2a7f0e9-3b14-4d86-8e5a-0f9d1b3c7a42",
"name": "Product media",
"slug": null,
"kind": "bucket",
"createdAt": "2026-07-02T09:12:45.003118",
"isPrivate": true
}
],
"nextCursor": "2026-07-02T09:12:45.003118"
}
```
**Response fields**
- `items[].id` (uuid): Bucket ID. Use it as `bucketId` when you create uploads.
- `items[].name` (string): Display name.
- `items[].slug` (string | null): Optional short identifier. The CLI and `findBucket()` accept it in place of the ID.
- `items[].isPrivate` (boolean): The bucket's default privacy. It decides delivery only for files whose visibility is `inherit`.
- `nextCursor` (string | null): Pass as `cursor` for the next page. It is set on every non-empty page, including the last one.
`nextCursor` alone does not tell you that more pages exist. Stop when a page returns fewer buckets than `limit`, or none.
##### Create a bucket
**POST /api/assets/** (scope: `assets:write`)
**Body**
- `name` (string): Display name, for example `Product media`.
- `slug` (string): Optional short identifier, up to 300 characters, for example `product-media`. Slugs are unique across all of SteadyLink, not only your workspace.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/" \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Product media", "slug": "acme-product-media" }'
```
```ts title="JavaScript"
const { id } = await steadylink.createBucket("Product media", "acme-product-media");
```
```python title="Python"
bucket = client.request("POST", "/api/assets/", {"name": "Product media", "slug": "acme-product-media"})
bucket_id = bucket["id"]
```
```json title="Response" status="201 Created"
{ "id": "c2a7f0e9-3b14-4d86-8e5a-0f9d1b3c7a42" }
```
A new bucket starts private or public according to the workspace's default visibility. Workspaces without a stored default start new buckets private, so nothing is published by accident. Change it afterwards with [Set a bucket's default privacy](#set-a-buckets-default-privacy) or [Update bucket settings](#update-bucket-settings).
**Note.**
Because slugs are global, a slug that another bucket already uses makes the request fail with a `500` error rather than a validation message. Prefix slugs with something specific to your organization, or omit the slug and use the bucket ID.
##### Get a bucket
**GET /api/assets/{bucket_id}** (scope: `assets:read`)
Returns the bucket with its settings. A bucket in another workspace returns `403`; an unknown ID returns `404 Asset not found`.
```bash title="curl"
curl "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const bucket = await steadylink.getBucket("7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15");
```
```python title="Python"
bucket = client.get_asset("7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15")
```
```json title="Response" status="200 OK"
{
"id": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"name": "Marketing",
"slug": "marketing",
"createdAt": "2026-08-17T18:31:00.412093",
"isPrivate": false,
"settings": {
"default_public": true,
"max_upload_mb": 50,
"allowed_mime_prefixes": ["image/", "application/pdf"]
}
}
```
`settings` is `null` until a setting has been saved. Its keys are described under [Update bucket settings](#update-bucket-settings).
##### Set a bucket's default privacy
**POST /api/assets/{bucket_id}/privacy** (scope: `assets:write`)
**Query parameters**
- `is_private` (boolean): `true` makes the bucket private, `false` makes it public.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/privacy?is_private=true" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{ "id": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15", "is_private": true }
```
This changes the bucket's own privacy flag, which governs delivery only for files whose visibility is `inherit`. Files uploaded through the API and dashboard receive an explicit `public` or `private` visibility when they are created, so flipping this flag does not change them. To make every file in a bucket private, set each file's visibility with [Set file visibility](https://steadylink.io/docs/reference/api/files#set-file-visibility). To change what new uploads get, set `default_public` in the bucket settings.
##### Update bucket settings
**POST /api/assets/{bucket_id}/settings** (scope: `assets:write`)
Send a JSON object with the settings to change. Settings you omit keep their current values, and keys not listed below are dropped. Setting keys are snake_case.
**Body**
- `default_public` (boolean): Visibility given to new files in this bucket: `true` for public, `false` for private. Without it, new files follow the workspace default.
- `max_upload_mb` (integer): Largest file this bucket accepts, in MiB. It can only tighten the plan's upload limit, never raise it.
- `allowed_mime_prefixes` (string[]): Accept only files whose detected type starts with one of these prefixes, for example `["image/", "application/pdf"]`. Send `[]` to allow all types.
- `allowed_extensions` (string[]): Accept only these filename extensions, with or without the leading dot, for example `["webp", ".pdf"]`. Send `[]` to allow all.
- `block_mime_mismatch` (boolean, default true): Reject an upload when the content type stored with its bytes and the type detected from the bytes are both concrete and belong to different families, for example `image/png` bytes that are actually a ZIP archive. Bytes sent to a presigned upload URL are always stored as `application/octet-stream`, which never conflicts, so this check mainly affects other upload paths.
- `html_download_only` (boolean): Serve HTML files as downloads (`Content-Disposition: attachment`) instead of rendering them. Downloads are the default.
- `svg_download_only` (boolean): Serve SVG files as downloads instead of rendering them inline. Downloads are the default.
- `pdf_sanitize_enabled` (boolean): Serve a sanitized copy of PDFs with active content removed.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/settings" \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"default_public": true,
"max_upload_mb": 50,
"allowed_mime_prefixes": ["image/", "application/pdf"]
}'
```
```python title="Python"
client.request(
"POST",
"/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/settings",
{"default_public": True, "max_upload_mb": 50, "allowed_mime_prefixes": ["image/", "application/pdf"]},
)
```
```json title="Response" status="200 OK"
{
"updated": true,
"settings": {
"default_public": true,
"max_upload_mb": 50,
"allowed_mime_prefixes": ["image/", "application/pdf"]
}
}
```
Upload policies are checked when an upload is finalized, after the bytes have been sent. A file that breaks the policy ends in the `blocked` state with the error code `policy` and a message such as `File type not allowed by bucket policy`. Check types and sizes before uploading if you want to fail faster. See [Uploads](https://steadylink.io/docs/reference/api/uploads#upload-states).
##### Delete a bucket
**DELETE /api/assets/{bucket_id}** (scope: `assets:write`)
Deletes the bucket, every file in it, every retained revision, cached image variants, and folder markers. Storage used by those revisions is released from the workspace's usage. Every stable link to a file in the bucket stops working.
**Warning.**
Deletion is immediate and permanent. There is no restore endpoint and no soft-delete period. Export anything you need first.
```bash title="curl"
curl -X DELETE "https://api.steadylink.io/api/assets/c2a7f0e9-3b14-4d86-8e5a-0f9d1b3c7a42" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.deleteBucket("c2a7f0e9-3b14-4d86-8e5a-0f9d1b3c7a42");
```
```python title="Python"
client.request("DELETE", "/api/assets/c2a7f0e9-3b14-4d86-8e5a-0f9d1b3c7a42")
```
```json title="Response" status="200 OK"
{ "deleted": true }
```
Passing a file's asset ID instead of a bucket ID returns `404 Bucket not found`. To delete one file, use [Delete a file](https://steadylink.io/docs/reference/api/files#delete-a-file).
#### Next steps
- [Files and folders](https://steadylink.io/docs/reference/api/files): List, search, move, and delete what is inside a bucket.
- [Uploads](https://steadylink.io/docs/reference/api/uploads): Put files into a bucket with upload batches.
- [Buckets and folders guide](https://steadylink.io/docs/guides/buckets-and-folders): How to organize buckets and folders in practice.
### REST API: Files and folders
Source: https://steadylink.io/docs/reference/api/files
Use these endpoints to work with what is inside a bucket: browse folders, find a file and its stable link, move it, make it public or private, publish new bytes under the same link, or delete it. To add new files, use [Uploads](https://steadylink.io/docs/reference/api/uploads).
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 files are identified
A file has three identifiers. Mixing them up is the most common source of `404` responses.
| Identifier | Example | What it is | Used by |
| --- | --- | --- | --- |
| Asset ID | `3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71` | The file's permanent identity. It appears in the delivery URL `https://cdn.steadylink.io/a/{asset_id}` and never changes, even when the file is renamed, moved, or replaced. Listings call it `objectAssetId`. | Revisions, signed links, delivery, [Get a file by asset ID](#get-a-file-by-asset-id) |
| Key | `campaign/launch/hero.webp` | The file's path inside its bucket. It changes when you rename or move the file. | Rename, visibility, replace, delete, [Get a file by key](#get-a-file-by-key) |
| Listing ID | `a91d3c70-...` | The `id` of a row in a folder listing. Internal bookkeeping. | Rarely needed |
Keys are bucket-relative, have no leading slash, and use `/` between folders. Send them URL-encoded in query strings.
#### Endpoints
| Operation | Method and path | Scope |
| --- | --- | --- |
| [List a folder](#list-a-folder) | `GET /api/assets/{bucket_id}/objects` | `assets:read` |
| [List every file in the workspace](#list-every-file-in-the-workspace) | `GET /api/assets/objects` | `assets:read` |
| [Get a file by asset ID](#get-a-file-by-asset-id) | `GET /api/assets/object/{asset_id}/stat` | `assets:read` |
| [Get a file by key](#get-a-file-by-key) | `GET /api/assets/{bucket_id}/objects/stat` | `assets:read` |
| [Search](#search) | `GET /api/assets/_search` | `assets:read` |
| [Create a folder](#create-a-folder) | `POST /api/assets/{bucket_id}/folders` | `assets:write` |
| [Rename or move a folder](#rename-or-move-a-folder) | `POST /api/assets/{bucket_id}/folders/rename` | `assets:write` |
| [Delete a folder](#delete-a-folder) | `DELETE /api/assets/{bucket_id}/folders` | `assets:write` |
| [Rename or move a file](#rename-or-move-a-file) | `POST /api/assets/{bucket_id}/objects/rename` | `assets:write` |
| [Set file visibility](#set-file-visibility) | `POST /api/assets/{bucket_id}/objects/visibility` | `assets:write` |
| [Create a replacement upload](#create-a-replacement-upload) | `POST /api/assets/{bucket_id}/objects/upload-temp` | `assets:write` |
| [Finish a replacement](#finish-a-replacement) | `POST /api/assets/{bucket_id}/objects/replace` | `assets:write` |
| [Delete a file](#delete-a-file) | `DELETE /api/assets/{bucket_id}/objects` | `assets:write` |
#### Browse and inspect
##### List a folder
**GET /api/assets/{bucket_id}/objects** (scope: `assets:read`)
Returns the folders and files directly inside one folder, not its subfolders' contents. Files are sorted by name. There is no pagination: the whole folder is returned at once.
**Query parameters**
- `prefix` (string): Folder path, for example `campaign/launch/`. A trailing slash is added if missing and a leading slash is removed. Omit it for the bucket root.
- `rev_only` (boolean, default false): Return only `prefix` and `revision`. Use it to poll cheaply for changes.
```bash title="curl"
curl "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects?prefix=campaign/" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const listing = await steadylink.listFiles("7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15", { prefix: "campaign" });
for (const file of listing.items) console.log(file.key, steadylink.link(file.objectAssetId!));
```
```python title="Python"
listing = client.request("GET", "/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects?prefix=campaign/")
```
```bash title="CLI"
steadylink ls marketing:campaign/
```
```json title="Response" status="200 OK"
{
"prefix": "campaign/",
"folders": [
{ "name": "launch", "path": "campaign/launch/" }
],
"items": [
{
"id": "a91d3c70-4e2b-4f18-9c6d-7b0e5a2f8c13",
"objectAssetId": "3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71",
"key": "campaign/hero.webp",
"name": "hero.webp",
"size": 2841024,
"lastModified": "2026-10-08T14:40:12.551930",
"visibility": "public",
"contentType": "image/webp"
}
],
"revision": "5c0f2b8e9a7d41c3b6e0f9a2d8c7b1e4f3a6d509"
}
```
**Response fields**
- `items[].objectAssetId` (uuid | null): The file's asset ID, used in its stable link.
- `items[].visibility` (string): `public` or `private`. When the file has no explicit visibility, the bucket's privacy is reported.
- `items[].size` (integer): Size of the current revision in bytes.
- `revision` (string | null): A hash of the folder's contents. It changes when a file in this folder is added, replaced, renamed, or removed, or a subfolder appears or disappears.
Files come straight from the database, so new and changed files appear immediately. Folder entries that exist only in storage are cached for up to three seconds. Compare `revision` values to decide whether to re-render a file browser.
##### List every file in the workspace
**GET /api/assets/objects** (scope: `assets:read`)
Returns every file in every bucket of the workspace as a flat list, without walking folders. Use it for inventories, audits, and sync jobs.
**Query parameters**
- `limit` (integer, default 1000): Files per page, 1 to 1000. Values outside the range return `400`.
- `cursor` (uuid): The `nextCursor` from the previous page. An invalid value returns `422 Invalid cursor`.
- `includeFolders` (boolean, default false): Also return every folder path in every bucket. This scans storage and is slower on large workspaces; request it once rather than on every page.
```bash title="curl"
curl "https://api.steadylink.io/api/assets/objects?limit=500" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
let cursor: string | undefined;
do {
const page = await steadylink.listAllFiles({ limit: 500, cursor });
for (const file of page.items) console.log(file.bucketName, file.key);
cursor = page.nextCursor ?? undefined;
} while (cursor);
```
```python title="Python"
cursor = None
while True:
query = "limit=500" + (f"&cursor={cursor}" if cursor else "")
page = client.request("GET", f"/api/assets/objects?{query}")
for item in page["items"]:
print(item["bucketName"], item["key"])
cursor = page["nextCursor"]
if not cursor:
break
```
```json title="Response" status="200 OK"
{
"items": [
{
"id": "a91d3c70-4e2b-4f18-9c6d-7b0e5a2f8c13",
"objectAssetId": "3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71",
"bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"bucketName": "Marketing",
"key": "campaign/hero.webp",
"name": "hero.webp",
"parent": "campaign/",
"size": 2841024,
"lastModified": "2026-10-08T14:40:12.551930",
"visibility": "public",
"contentType": "image/webp"
}
],
"nextCursor": null,
"folders": []
}
```
`nextCursor` is `null` on the last page. Items are ordered by listing ID, not by name or date.
##### Get a file by asset ID
**GET /api/assets/object/{asset_id}/stat** (scope: `assets:read`)
Returns a file's bucket, key, and details of its current revision, or of one revision with `v`. This is the most reliable way to go from a stable link back to the file.
**Query parameters**
- `v` (integer): Revision number to describe. Omit it for the current revision.
```bash title="curl"
curl "https://api.steadylink.io/api/assets/object/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/stat" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const file = await steadylink.getFile("3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71");
console.log(file.bucketId, file.key, file.scanStatus);
```
```python title="Python"
file = client.request("GET", "/api/assets/object/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/stat")
```
```bash title="CLI"
steadylink inspect 3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71
```
```json title="Response" status="200 OK"
{
"bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"key": "campaign/hero.webp",
"id": null,
"objectAssetId": "3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71",
"size": 2841024,
"contentType": "image/webp",
"etag": "9b2e6f0c4a7d18e3b5f2c9a0d6e4b7f1a3c8e2d5f0b9a6c4e7d1f3b8a2c5e9d0",
"lastModified": "2026-10-08T14:40:12.551930",
"metadata": {
"image": { "width": 2400, "height": 1600, "format": "WEBP", "mode": "RGB", "animated": false, "frames": 1 }
},
"metadataStatus": "complete",
"assetVersionId": "e4d7b1a9-2c6f-4083-9a5e-1b8d3f0c6e27",
"scanStatus": "clean",
"scanEngine": "clamav",
"scanAt": "2026-10-08T14:40:15.002461",
"scanDetails": null
}
```
**Response fields**
- `etag` (string): SHA-256 of the revision's bytes. The same value is the delivery `ETag`.
- `metadata` (object): Metadata extracted after upload. For raster images it has an `image` object (width, height, format, color mode, animation) and, when present, up to 100 `exif` tags. Other file types get an empty object.
- `metadataStatus` (string): `pending` or `processing` until extraction has run, then `complete`, or `error` if the file could not be read.
- `scanStatus` (string | null): `pending`, `clean`, `infected`, or `error`. `null` when scanning is off. Only `clean` revisions are delivered.
- `assetVersionId` (uuid): ID of the revision described.
A `v` that does not exist, or an ID that is not a file, returns `404 Not found`.
##### Get a file by key
**GET /api/assets/{bucket_id}/objects/stat** (scope: `assets:read`)
Looks a file up by its path and returns its asset ID, size, and storage metadata.
**Query parameters**
- `key` (string): Bucket-relative key, for example `campaign/hero.webp`.
- `exif` (boolean, default false): For images, add EXIF tags and `image_width`, `image_height`, and `image_format` to `metadata`.
```bash title="curl"
curl "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects/stat?key=campaign%2Fhero.webp" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const file = await steadylink.statFile("7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15", "campaign/hero.webp");
```
```python title="Python"
from urllib.parse import quote
file = client.request("GET", f"/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects/stat?key={quote('campaign/hero.webp', safe='')}")
```
```json title="Response" status="200 OK"
{
"key": "campaign/hero.webp",
"id": "a91d3c70-4e2b-4f18-9c6d-7b0e5a2f8c13",
"objectAssetId": "3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71",
"size": 0,
"contentType": "application/octet-stream",
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"lastModified": "2026-10-08T14:52:03+00:00",
"metadata": { "asset-id": "3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71" }
}
```
This route reads the file's entry in bucket storage, not its revision. For files whose bytes live in revisions, the entry is a small marker, so `size`, `contentType`, and `etag` describe the marker rather than the file. Rely on `objectAssetId` from this response, then call [Get a file by asset ID](#get-a-file-by-asset-id) for the real size, type, and scan state. When no storage entry exists for the key, the route returns `404 Not found` even if the file appears in listings; in that case find the asset ID from [List a folder](#list-a-folder).
##### Search
**GET /api/assets/_search** (scope: `assets:read`)
Finds files by name or path, buckets by name, PDFs by their extracted text, and recent workspace activity.
**Query parameters**
- `q` (string): Search text. Matching is case-insensitive and finds the text anywhere in the name or path. An empty value returns no results.
- `scope` (string, default org): `org` searches the whole workspace. `bucket` searches one bucket and skips bucket names and activity.
- `bucket_id` (uuid): Required when `scope=bucket`. Missing returns `400`.
- `limit` (integer, default 20): Maximum results, clamped to 1 to 50.
```bash title="curl"
curl "https://api.steadylink.io/api/assets/_search?q=hero&limit=10" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"query": "hero",
"scope": "org",
"items": [
{
"type": "object",
"assetId": "3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71",
"bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"name": "hero.webp",
"path": "campaign/hero.webp",
"visibility": "public"
},
{
"type": "content",
"assetId": "8d0b4e2f-6a19-4c73-b5e8-0f2d7a9c1b46",
"bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"name": "brand-guide.pdf",
"path": "guides/brand-guide.pdf",
"version": 3,
"snippet": "...place the hero image above the fold on every landing page..."
}
]
}
```
Results have a `type`: `object` (a file whose name or path matches), `bucket`, `content` (text inside a PDF, with the revision `version` and a `snippet`), or `event` (an activity entry with `createdAt`). Exact name matches come first, then prefix matches, then other matches, then content and events.
#### Folders
Folders are paths. A file's folder is the part of its key before the last `/`, and a folder exists as long as a file uses it or it was created explicitly. You do not need to create a folder before uploading into it: an upload with `path: "campaign/launch/"` creates the path.
##### Create a folder
**POST /api/assets/{bucket_id}/folders** (scope: `assets:write`)
Creates an empty folder so it shows up in listings before any file is added.
**Query parameters**
- `path` (string): Folder path, for example `campaign/launch`. Leading slashes are removed and a trailing slash is added.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/folders?path=campaign/launch" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.createFolder("7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15", "campaign/launch");
```
```python title="Python"
client.request("POST", "/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/folders?path=campaign/launch")
```
```json title="Response" status="200 OK"
{ "created": true }
```
##### Rename or move a folder
**POST /api/assets/{bucket_id}/folders/rename** (scope: `assets:write`)
Moves a folder and everything under it to a new path. The files keep their asset IDs and stable links; only their keys change. `POST /api/assets/{bucket_id}/folders/move` is an alias with the same parameters.
**Query parameters**
- `from_path` (string): Current folder path, for example `campaign/launch`.
- `to_path` (string): New folder path, for example `archive/2026/launch`.
- `overwrite` (boolean, default false): Accepted for compatibility. Do not rely on the route to protect an existing destination: check that `to_path` is empty or new before you move.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/folders/rename?from_path=campaign/launch&to_path=archive/2026/launch" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{ "renamed": true, "moved": 1 }
```
`moved` counts storage entries that were copied, which can differ from the number of files. Moving a folder into its own subtree, such as `campaign` into `campaign/old`, returns `400`.
##### Delete a folder
**DELETE /api/assets/{bucket_id}/folders** (scope: `assets:write`)
**Query parameters**
- `path` (string): Folder path to remove, for example `campaign/old`.
```bash title="curl"
curl -X DELETE "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/folders?path=campaign/old" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{ "deleted": true, "objectsDeleted": 4 }
```
Removes the folder and every file listing under it, including subfolders. `objectsDeleted` counts storage entries removed, not files. It does not delete the files' revisions, so their storage is not released. When you want files gone completely, [delete each file](#delete-a-file) first, then the folder.
#### Change a file
##### Rename or move a file
**POST /api/assets/{bucket_id}/objects/rename** (scope: `assets:write`)
Changes a file's key. Use a key in another folder to move it. The asset ID, stable link, and revisions do not change, so nothing that links to the file breaks.
**Query parameters**
- `from_key` (string): Current key, for example `campaign/hero.webp`.
- `to_key` (string): New key, for example `campaign/launch/hero.webp`. If a file already uses it, the request returns `409 Destination already exists`.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects/rename?from_key=campaign%2Fhero.webp&to_key=campaign%2Flaunch%2Fhero.webp" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.renameFile("7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15", "campaign/hero.webp", "campaign/launch/hero.webp");
```
```python title="Python"
from urllib.parse import urlencode
query = urlencode({"from_key": "campaign/hero.webp", "to_key": "campaign/launch/hero.webp"})
client.request("POST", f"/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects/rename?{query}")
```
```json title="Response" status="200 OK"
{ "renamed": true }
```
The route answers `{"renamed": true}` even when no file has `from_key`. If you are not sure the key exists, check it in a listing first.
##### Set file visibility
**POST /api/assets/{bucket_id}/objects/visibility** (scope: `assets:write`)
Makes one file public, private, or dependent on its bucket.
**Query parameters**
- `key` (string): Bucket-relative key.
- `visibility` (string): `public` serves the file to anyone with the link. `private` requires a [signed link](https://steadylink.io/docs/reference/api/delivery#create-a-signed-link). `inherit` follows the bucket's privacy. Anything else returns `400`.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects/visibility?key=campaign%2Fhero.webp&visibility=private" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.setVisibility("7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15", "campaign/hero.webp", "private");
```
```python title="Python"
client.request("POST", "/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects/visibility?key=campaign%2Fhero.webp&visibility=private")
```
```json title="Response" status="200 OK"
{ "updated": true }
```
Public responses may already be cached by browsers and shared caches for up to five minutes. Making a file private stops new public responses right away but cannot recall copies that were already cached. See [Caching](https://steadylink.io/docs/reference/api/delivery#caching).
#### Replace a file
Replacing a file publishes new bytes as the next revision of the same asset. The asset ID and every link to it stay the same, and the previous revisions are kept so you can [roll back](https://steadylink.io/docs/reference/api/revisions#promote-a-revision). This takes three requests: reserve a temporary upload, send the bytes, and finish.
```ts title="JavaScript"
import { fileFromPath } from "@steadylink/sdk/node";
// By asset ID...
const result = await steadylink.replace(
"3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71",
await fileFromPath("./hero-v2.webp"),
);
// ...or by bucket and key
await steadylink.replace(
{ bucketId: "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15", key: "campaign/hero.webp" },
await fileFromPath("./hero-v2.webp"),
);
console.log(result.version, result.url); // new revision number, same link
```
```python title="Python"
from pathlib import Path
bucket_id = "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15"
data = Path("hero-v2.webp").read_bytes()
pending = client.create_replacement_upload(bucket_id, len(data), "image/webp")
client.upload_to_url(pending["uploadUrl"], data) # signed for application/octet-stream
result = client.finish_replacement(bucket_id, "campaign/hero.webp", pending["tempKey"], "hero-v2.webp")
print(result["version"])
```
```bash title="CLI"
steadylink replace 3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71 ./hero-v2.webp
steadylink replace marketing:campaign/hero.webp ./hero-v2.webp
```
##### Create a replacement upload
**POST /api/assets/{bucket_id}/objects/upload-temp** (scope: `assets:write`)
Returns a presigned URL for the new bytes and a temporary key that identifies them.
**Query parameters**
- `size` (integer): Exact size of the new file in bytes. Larger than the plan's upload limit returns `413` with the code `upload_size_limit`.
- `content_type` (string, default application/octet-stream): The file's type, recorded for your audit trail. The stored type is detected from the bytes when you finish.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects/upload-temp?size=2950112&content_type=image%2Fwebp" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"uploadUrl": "https://storage-endpoint.example/uploads/temp/52e8a1c7-...?X-Amz-Signature=...",
"tempKey": "uploads/temp/52e8a1c7-9d3b-4f60-8e2a-b4c1d7f09a35"
}
```
Then send the bytes to `uploadUrl` with a `PUT` and `Content-Type: application/octet-stream`. The URL is signed for that content type, so any other value fails with `403` from storage. It expires after 15 minutes. Do not send your API key to it.
```bash title="curl"
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/octet-stream" \
--data-binary @hero-v2.webp
```
##### Finish a replacement
**POST /api/assets/{bucket_id}/objects/replace** (scope: `assets:write`)
Turns the uploaded bytes into the file's next revision and makes it current.
**Query parameters**
- `key` (string): Key of the file to replace, for example `campaign/hero.webp`.
- `upload_temp_key` (string): The `tempKey` from [Create a replacement upload](#create-a-replacement-upload). If no bytes were uploaded to it, the request returns `400 upload_temp_key not found`.
- `original_filename` (string): Filename of the new bytes, for example `hero-v2.webp`. It is used as the revision's stored filename, which delivery uses in `Content-Disposition`. The key does not change.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects/replace?key=campaign%2Fhero.webp&upload_temp_key=uploads%2Ftemp%2F52e8a1c7-9d3b-4f60-8e2a-b4c1d7f09a35&original_filename=hero-v2.webp" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{ "replaced": true, "version": 4 }
```
What to expect:
- **The new revision becomes current immediately.** While its malware scan is running, the stable link answers `423 Scanning`. Once the scan finishes clean, the link serves the new bytes. If the scan finds malware, the link answers `403` until you [promote](https://steadylink.io/docs/reference/api/revisions#promote-a-revision) an earlier clean revision.
- **Caches catch up within minutes.** Browsers and shared caches can keep the previous bytes for up to five minutes. Links pinned with `?v=` never change. See [Caching](https://steadylink.io/docs/reference/api/delivery#caching).
- **Storage is reserved before the bytes are stored.** If the new revision would push the workspace over its storage limit, the request returns `413`. Every revision you keep counts toward storage.
- **A key with no file creates one.** If nothing exists at `key`, the request creates a new file there with a new asset ID. Check `version`: `1` means a new file was created.
#### Delete
##### Delete a file
**DELETE /api/assets/{bucket_id}/objects** (scope: `assets:write`)
Deletes a file, every revision, and every cached image variant, and releases their storage. The stable link stops working and returns `404`.
**Query parameters**
- `key` (string): Bucket-relative key, for example `campaign/old-hero.webp`.
```bash title="curl"
curl -X DELETE "https://api.steadylink.io/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects?key=campaign%2Fold-hero.webp" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.deleteFile("7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15", "campaign/old-hero.webp");
```
```python title="Python"
client.request("DELETE", "/api/assets/7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15/objects?key=campaign%2Fold-hero.webp")
```
```bash title="CLI"
steadylink rm marketing:campaign/old-hero.webp --yes
```
```json title="Response" status="200 OK"
{ "deleted": true }
```
**Warning.**
There is no undo. If the file might be needed again, make it private instead of deleting it. The route also returns `{"deleted": true}` for a key that does not exist, so a typo looks like success.
To delete only an old revision and keep the file, use [Delete a revision](https://steadylink.io/docs/reference/api/revisions#delete-a-revision).
#### Next steps
- [Uploads](https://steadylink.io/docs/reference/api/uploads): Add new files with upload batches.
- [Revisions](https://steadylink.io/docs/reference/api/revisions): Inspect, label, and roll back what a replacement created.
- [Delivery and signed links](https://steadylink.io/docs/reference/api/delivery): Serve files and share private ones.
### REST API: Uploads
Source: https://steadylink.io/docs/reference/api/uploads
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](https://steadylink.io/docs/reference/api/files#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
Each file in a batch moves through these states on its own. Ready, blocked, failed, and cancelled are final.
**Create a batch**
`POST /api/upload-batches` with the bucket and up to 100 files. SteadyLink reserves storage for them and returns one upload session per file, each with a presigned `uploadUrl`.
**Send the bytes**
`PUT` each file to its `uploadUrl`. 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}/complete` with an `Idempotency-Key`. SteadyLink queues finalization and answers `202` immediately.
**Wait for a final state**
Poll the batch or listen to the event stream until each session is `ready`, `blocked`, `failed`, or `cancelled`. A `ready` session has an `objectAssetId`: the file's stable link is `https://cdn.steadylink.io/a/{objectAssetId}`.
If you use the SDK or CLI, one call does all four steps:
```ts title="JavaScript"
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-...
```
```python title="Python"
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 state
```
```bash title="CLI"
steadylink upload ./campaign-hero.webp --bucket marketing --folder campaign/launch --public
```
The Python helper returns as soon as the session is completed, before finalization. Poll [Get an upload batch](#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](#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](https://steadylink.io/docs/reference/api/buckets#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
**POST /api/upload-batches** (scope: `assets:write`)
Creates one upload session per file and returns presigned upload URLs. All files go into one bucket; each can have its own folder.
**Body**
- `bucketId` (uuid): Destination bucket. A file asset ID returns `400 Uploads require a bucket`.
- `files` (object[]): 1 to 100 files. Split larger sets into several batches.
- `files[].filename` (string): File name, 1 to 512 characters, for example `campaign-hero.webp`. Any directory part is removed: `a/b/c.png` becomes `c.png`. Use `path` for folders.
- `files[].size` (integer): Exact size in bytes, at least 1. Missing returns `422` with the code `upload_size_required`. Empty files cannot be uploaded.
- `files[].path` (string): 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 returns `422 Invalid upload path`.
- `files[].contentType` (string, default application/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.
```bash title="curl"
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 }
]
}'
```
```ts title="JavaScript"
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 },
]);
```
```python title="Python"
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/"),
])
```
```json title="Response" status="201 Created"
{
"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:
- **`uploadUrl` expires 15 minutes after the batch is created.** Send the bytes promptly. If the URL expires first, cancel the session and create a new batch.
- **`expiresAt` is when the session itself expires**, by default 24 hours after creation. Completing a session after that marks it `failed` with `upload_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](https://steadylink.io/docs/reference/api/authentication#plan-access): 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
**PUT {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-Type` (string): Exactly `application/octet-stream`, whatever the file's real type. The URL is signed for this value, and any other value fails with `403` from storage. The stored type is detected from the bytes later.
```bash title="curl"
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/octet-stream" \
--data-binary @campaign-hero.webp
```
```ts title="JavaScript"
import { 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)}%`),
});
```
```python title="Python"
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](https://steadylink.io/docs/guides/browser-uploads).
##### Upload bytes through the API
**PUT /api/upload-sessions/{session_id}/content** (scope: `assets:write`)
Streams 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-Type` (string): `application/octet-stream`.
- `Content-Length` (integer): If sent, it must equal the session's `expectedSize`, or the request returns `400 Uploaded file size does not match`.
```bash title="curl"
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.webp
```
```ts title="JavaScript"
await steadylink.sendUploadBytes(session, await readFile("./campaign-hero.webp"), { via: "api" });
// Or for the whole upload() helper
await steadylink.upload(bucketId, files, { via: "api" });
```
```bash title="CLI"
steadylink upload ./campaign-hero.webp --bucket marketing --via api
```
The 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
**POST /api/upload-sessions/{session_id}/complete** (scope: `assets:write`)
Tells 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-Key` (string): Any string unique to this session, for example `complete-9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64`. Reuse the same value when you retry.
```bash title="curl"
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"
```
```ts title="JavaScript"
// Sends Idempotency-Key: complete-{session_id} and retries safely
const session = await steadylink.completeUpload("9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64");
```
```python title="Python"
session = client.complete_upload("9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64")
```
```json title="Response" status="202 Accepted"
{
"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 returns `409 Upload is already committing`, even with the same `Idempotency-Key`. Treat this as "your earlier request went through" and poll for the result.
- **A different `Idempotency-Key`** than the one recorded for the session returns `409 Idempotency key mismatch`.
- **The session expired**: the session is marked `failed` with `upload_expired`, its storage reservation is released, and the request returns `410`.
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
**GET /api/upload-batches/{batch_id}** (scope: `assets:read`)
Returns the batch with counts and every session's current state. Poll it until the sessions you care about are final.
```bash title="curl"
curl "https://api.steadylink.io/api/upload-batches/b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
// 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");
```
```python title="Python"
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)
```
```json title="Response" status="200 OK"
{
"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**
- `completedFiles` (integer): Sessions that are `ready`.
- `failedFiles` (integer): Sessions that are `blocked` or `failed`.
- `files[].objectAssetId` (uuid | null): The file's asset ID, set once the revision is created (from `scanning` on).
- `files[].revisionNumber` (integer | null): The revision this upload created. `1` for 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
**GET /api/events/stream** (scope: `assets:read`)
Opens a [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/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.
```bash title="curl"
curl -N "https://api.steadylink.io/api/events/stream" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```text title="Stream"
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](#get-an-upload-batch); the example above is shortened.
- Every session in the workspace is included, from every user and integration. Filter by `batchId` or `id`.
- 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 `: keepalive` comment arrives about every 15 seconds when nothing changes. If you see neither events nor keepalives for longer than that, reconnect.
- The browser `EventSource` API cannot send an `X-API-Key` header. Consume the stream on your server, or use `fetch` with a streaming body.
##### Cancel an upload session
**DELETE /api/upload-sessions/{session_id}** (scope: `assets:write`)
Cancels a session that has not finished, deletes any bytes already uploaded, and releases its storage reservation.
```bash title="curl"
curl -X DELETE "https://api.steadylink.io/api/upload-sessions/2b8f6d14-9a3e-4c71-b0d5-e7f2a1c8b396" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.cancelUpload("2b8f6d14-9a3e-4c71-b0d5-e7f2a1c8b396");
```
```python title="Python"
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](https://steadylink.io/docs/reference/api/files#delete-a-file). 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](https://steadylink.io/docs/reference/api/authentication#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](https://steadylink.io/docs/reference/api/authentication#plan-access). The `PUT` to a presigned URL does not count. |
#### Next steps
- [Browser uploads](https://steadylink.io/docs/guides/browser-uploads): Let people upload from your web app without exposing your key.
- [Replace a file](https://steadylink.io/docs/reference/api/files#replace-a-file): Publish new bytes under an existing link.
- [Delivery and signed links](https://steadylink.io/docs/reference/api/delivery): Serve what you uploaded and resize images on the fly.
### REST API: Revisions
Source: https://steadylink.io/docs/reference/api/revisions
Every time a file gets new bytes, SteadyLink keeps the old bytes as a revision and makes the new one current. The file's asset ID and link never change; the link simply serves whichever revision is current. Use these endpoints to see a file's history, annotate it, undo a bad replacement in one request, and clean up revisions that take up storage.
Revisions belong to a file, so every route here takes the file's **asset ID** (the ID in `https://cdn.steadylink.io/a/{asset_id}`), not the bucket ID. Revisions are addressed by their `versionNumber`: 1 for the first upload, then 2, 3, and so on.
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 revisions are created
A new revision is created when:
- you [replace a file](https://steadylink.io/docs/reference/api/files#replace-a-file),
- an [upload](https://steadylink.io/docs/reference/api/uploads) lands on a key that already has a file,
- someone approves a [replacement request](https://steadylink.io/docs/reference/api/requests#replacement-requests) or an upload widget submission for the file.
The new revision becomes current. The previous ones stay retained, and each counts toward the workspace's storage until you delete it. Revisions are immutable: you can label them, promote them, or delete them, but never change their bytes.
A revision is delivered only after its malware scan is clean. While a current revision is still scanning, the link answers `423`. See [Delivery](https://steadylink.io/docs/reference/api/delivery#deliver-a-file).
| Endpoint | Method and path | Scope |
| --- | --- | --- |
| [List revisions](#list-revisions) | `GET /api/assets/{asset_id}/versions` | `assets:read` |
| [Label a revision](#label-a-revision) | `PATCH /api/assets/{asset_id}/versions/{version}` | `assets:write` |
| [Promote a revision](#promote-a-revision) | `POST /api/assets/{asset_id}/versions/{version}/promote` | `assets:write` |
| [Delete a revision](#delete-a-revision) | `DELETE /api/assets/{asset_id}/versions/{version}` | `assets:write` |
#### Operations
##### List revisions
**GET /api/assets/{asset_id}/versions** (scope: `assets:read`)
Returns every retained revision, newest first, as a JSON array. There is no pagination.
```bash title="curl"
curl "https://api.steadylink.io/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/versions" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const revisions = await steadylink.listVersions("3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71");
const current = revisions.find((revision) => revision.isCurrent);
```
```python title="Python"
revisions = client.request("GET", "/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/versions")
current = next(r for r in revisions if r["isCurrent"])
```
```bash title="CLI"
steadylink versions 3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71
```
```json title="Response" status="200 OK"
[
{
"versionNumber": 4,
"storageKey": "assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/versions/4/hero-v2.webp",
"byteSize": 2950112,
"width": 2400,
"height": 1600,
"mime": "image/webp",
"hash": "c47e1d9a0b3f62e8d5a9f1c0b7e4d2a68f3c9e1b0d7a5f2c8e6b4d1a9f0c3e7b",
"id": "5d8a2f1c-7e3b-4a90-b6c4-e0f9d2a1b738",
"label": "Launch approved",
"note": "Final color correction from the agency",
"createdBy": null,
"isCurrent": true,
"scanStatus": "clean",
"metadataStatus": "complete",
"createdAt": "2026-10-08T15:10:44.391027"
},
{
"versionNumber": 3,
"storageKey": "assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/versions/3/hero.webp",
"byteSize": 2841024,
"width": 2400,
"height": 1600,
"mime": "image/webp",
"hash": "9b2e6f0c4a7d18e3b5f2c9a0d6e4b7f1a3c8e2d5f0b9a6c4e7d1f3b8a2c5e9d0",
"id": "e4d7b1a9-2c6f-4083-9a5e-1b8d3f0c6e27",
"label": null,
"note": null,
"createdBy": "1a7c4e92-8b3d-4f06-a5e1-c2d9b0f7e384",
"isCurrent": false,
"scanStatus": "clean",
"metadataStatus": "complete",
"createdAt": "2026-10-01T09:02:18.775310"
}
]
```
**Response fields**
- `versionNumber` (integer): The revision's number. Use it in `?v=` delivery URLs and in the routes below.
- `id` (uuid): The revision's own ID. Webhooks and some responses call it `revisionId`.
- `isCurrent` (boolean): `true` for the revision the stable link serves. Exactly one revision is current while any exist.
- `hash` (string): SHA-256 of the bytes. Compare hashes to see whether two revisions are identical.
- `byteSize` (integer): Stored size. Every retained revision counts toward storage.
- `width, height` (integer | null): Pixel dimensions for images, otherwise `null`.
- `label, note` (string | null): Your annotations. See [Label a revision](#label-a-revision).
- `createdBy` (uuid | null): The user who created the revision. `null` for revisions created with an API key, through an upload batch, or by an automated workflow.
- `scanStatus` (string | null): `pending`, `clean`, `infected`, or `error`. `null` when scanning is off.
- `metadataStatus` (string): `pending`, `processing`, `complete`, or `error`.
A file ID from another workspace returns `403`, and an unknown ID returns `404 Asset not found`. A bucket ID returns an empty array.
##### Label a revision
**PATCH /api/assets/{asset_id}/versions/{version}** (scope: `assets:write`)
Sets the human-readable label and note on one revision, for example to record who approved it. Both fields are written on every call: a field you omit is cleared. Send the current value of the other field if you only want to change one.
**Body**
- `label` (string | null): Short label, up to 120 characters, such as `Launch approved`. Empty or whitespace-only clears it.
- `note` (string | null): Longer note, up to 1000 characters. Empty or whitespace-only clears it.
```bash title="curl"
curl -X PATCH "https://api.steadylink.io/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/versions/4" \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "label": "Launch approved", "note": "Final color correction from the agency" }'
```
```ts title="JavaScript"
await steadylink.updateVersion("3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71", 4, {
label: "Launch approved",
note: "Final color correction from the agency",
});
```
```python title="Python"
client.request("PATCH", "/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/versions/4", {
"label": "Launch approved",
"note": "Final color correction from the agency",
})
```
```json title="Response" status="200 OK"
{
"id": "5d8a2f1c-7e3b-4a90-b6c4-e0f9d2a1b738",
"versionNumber": 4,
"label": "Launch approved",
"note": "Final color correction from the agency"
}
```
A label longer than 120 characters or a note longer than 1000 returns `400 Validation error`. A revision number that does not exist returns `404 Revision not found`.
##### Promote a revision
**POST /api/assets/{asset_id}/versions/{version}/promote** (scope: `assets:write`)
Makes an existing revision current. This is how you roll back: promote the last good revision and the stable link serves it again. Promotion does not copy bytes or create a new revision, so it is instant and uses no extra storage. You can promote forward again later; nothing is lost.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/versions/3/promote" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.promoteVersion("3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71", 3);
// rollback() is an alias
await steadylink.rollback("3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71", 3);
```
```python title="Python"
client.request("POST", "/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/versions/3/promote")
```
```bash title="CLI"
steadylink rollback 3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71 3
```
```json title="Response" status="200 OK"
{
"promoted": true,
"versionNumber": 3,
"revisionId": "e4d7b1a9-2c6f-4083-9a5e-1b8d3f0c6e27"
}
```
| Status | Message | Cause |
| --- | --- | --- |
| `404` | `Revision not found` | No revision with that number. Revision numbers are per file; list them first. |
| `409` | `Only clean revisions can be promoted` | The revision's scan is still `pending`, or it ended `infected` or `error`. Only clean revisions can become current. |
After a promotion, SteadyLink's own lookups switch immediately, but browsers and shared caches can keep serving the previous revision for up to five minutes. URLs pinned with `?v=` are unaffected. See [Caching](https://steadylink.io/docs/reference/api/delivery#caching).
To make a revision current at a future time, with an optional automatic rollback, use [scheduled revisions](https://steadylink.io/docs/reference/api/requests#scheduled-revisions).
##### Delete a revision
**DELETE /api/assets/{asset_id}/versions/{version}** (scope: `assets:write`)
Permanently deletes one revision's bytes and its cached image variants, and releases its storage.
**Query parameters**
- `force` (boolean, default false): Required to delete the current revision. Without it, deleting the current revision returns `400 Cannot delete current version without force=true`.
```bash title="curl"
curl -X DELETE "https://api.steadylink.io/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/versions/2" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.deleteVersion("3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71", 2);
// The current revision needs force
await steadylink.deleteVersion("3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71", 4, { force: true });
```
```python title="Python"
client.request("DELETE", "/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/versions/2")
```
```json title="Response" status="200 OK"
{ "deleted": true }
```
What happens next depends on which revision you delete:
- **An older revision.** It disappears from the list. URLs pinned to it with `?v=` return `404` once caches expire.
- **The current revision, with `force=true`.** The highest-numbered remaining revision becomes current, even if it is not the one that was current before. Promote a different one afterwards if needed.
- **The last remaining revision.** The file itself is deleted: its listing, its asset ID, and its link all go away, as with [Delete a file](https://steadylink.io/docs/reference/api/files#delete-a-file).
An unknown revision number returns `404 Version not found`.
**Warning.**
Revision numbers are assigned as one more than the highest remaining revision. If you delete the newest revision, the next upload reuses its number. Responses pinned with `?v=` are cached for a year, so a cache that still holds the deleted revision under that number can keep serving it. Prefer promoting an older revision over deleting the newest one if pinned URLs might be in use.
#### Next steps
- [Replace a file](https://steadylink.io/docs/reference/api/files#replace-a-file): Create a new revision without changing the link.
- [How revisions work](https://steadylink.io/docs/concepts/revisions): Current and pinned revisions, retention, and rollback in depth.
- [Pin a revision in a URL](https://steadylink.io/docs/reference/api/delivery#deliver-a-file): Serve one specific revision forever with `?v=`.
### REST API: Delivery and signed links
Source: https://steadylink.io/docs/reference/api/delivery
This page covers what happens after a file is uploaded: how its stable link serves bytes, which query parameters resize and convert images, how long responses are cached, and how to give someone time-limited access to a private file with a signed link.
Delivery uses its own origin, `https://cdn.steadylink.io`, and never takes an API key. Managing signed links uses the API origin and a key like every other route.
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"])`.
#### Endpoints
| Operation | Method and path | Access |
| --- | --- | --- |
| [Deliver a file](#deliver-a-file) | `GET` or `HEAD https://cdn.steadylink.io/a/{asset_id}` | Public, or a signed token for private files |
| [Create a signed link](#create-a-signed-link) | `POST /api/assets/{asset_id}/signed-url` | `assets:write` |
| [List signed links](#list-signed-links) | `GET /api/assets/{asset_id}/signed-urls` | `assets:read` |
| [Revoke a signed link](#revoke-a-signed-link) | `DELETE /api/assets/{asset_id}/signed-urls/{grant_id}` | `assets:write` |
| [Get a preview URL](#get-a-preview-url) | `GET /api/assets/object/{asset_id}/preview` | `assets:read` |
| [Purge cached image variants](#purge-cached-image-variants) | `POST /api/assets/{asset_id}/purge` | `assets:write` |
#### Delivery
##### Deliver a file
**GET /a/{asset_id}**
Serves the file's current revision, or a specific revision with `v`. Without parameters it returns the original bytes. With image parameters it returns a resized or converted variant, which is generated on the first request and stored for later ones. `HEAD` is also supported and returns the same headers without a body.
**Query parameters**
- `v` (integer): Serve this revision number instead of the current one. Pinned responses are cached for a year. Omit it to follow the current revision.
- `token` (string): Signed token from [Create a signed link](#create-a-signed-link). Required for private files, ignored for public ones.
- `w` (integer): Target width in pixels, 1 to 8000. Alias: `width`.
- `h` (integer): Target height in pixels, 1 to 8000. Alias: `height`.
- `fit` (string, default contain): `cover` scales and crops to exactly `w` by `h`. `contain`, `inside`, and `outside` are accepted and currently behave the same: the image is scaled down to fit within the box, keeping its aspect ratio, and is never enlarged.
- `fm` (string): Output format: `webp`, `jpg` (or `jpeg`), or `png`. Alias: `format`. When an image is resized without `fm`, the result is JPEG, so set `fm=png` or `fm=webp` to keep transparency.
- `q` (integer, default 82): Output quality for JPEG and WebP, 30 to 95. Values outside the range return `400`. Alias: `quality`. On its own, `q` does not create a variant; combine it with `w`, `h`, or `fm`.
- `dpr` (number, default 1): Device pixel ratio, 0.1 to 3. Without `w` and `h` it scales the original, for example `dpr=0.5` halves it. With `w` or `h` it does not change the output size: request the pixel width you need directly, such as `w=1200` for a 600-pixel slot on a 2x screen. `w` or `h` multiplied by `dpr` must stay within 8000.
- `allowUpscale` (boolean, default false): For `fit=cover`, allow the output to exceed twice the source dimensions. Without it, cover output is capped at 2x the source width and height. Other fits never enlarge. Accepts `true`, `false`, `1`, `0`, `yes`, `no`.
- `focus` (string, default center): `auto` picks a high-detail area to keep when cropping. Requires `fit=cover`.
- `fp-x, fp-y` (number): Explicit focal point, each from 0 (left or top) to 1 (right or bottom). Send both, with `fit=cover`, and without `focus=auto`. Aliases: `focal-x`, `focal-y`.
- `preset` (string): Apply a named public transform preset from **Dashboard > Developers > Transforms**. Cannot be combined with other image parameters except `v`.
- `showcase` (flag): Return a minimal HTML page that displays the image. Images only, and not combinable with image parameters.
- `ref, utm_source, utm_medium, utm_campaign, utm_content, utm_term` (string): Attribution parameters. They do not affect the response and are recorded as [traffic sources](https://steadylink.io/docs/reference/api/analytics#traffic-sources).
```text title="Examples"
# Current revision, original bytes
https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71
# Revision 3, forever
https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71?v=3
# 1200 x 630 social card, cropped around the most detailed area, as WebP
https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71?w=1200&h=630&fit=cover&focus=auto&fm=webp
# 1200 pixels wide (a 600-pixel slot on a 2x screen) as WebP at quality 75
https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71?w=1200&fm=webp&q=75
# Private file with a signed token
https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71?token=v2.3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71.1791472980.1c8e5a3f-2d7b-4a9c-b6e1-8f0d2c4a6b97.4b1e...
```
Build links in code rather than by hand:
```ts title="JavaScript"
const url = steadylink.link("3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71", {
width: 1200,
height: 630,
fit: "cover",
focus: "auto",
format: "webp",
});
```
```python title="Python"
from urllib.parse import urlencode
asset_id = "3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71"
params = {"w": 1200, "h": 630, "fit": "cover", "focus": "auto", "fm": "webp"}
url = f"https://cdn.steadylink.io/a/{asset_id}?{urlencode(params)}"
```
```bash title="CLI"
steadylink link 3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71 --w 1200 --h 630 --fit cover --fm webp
```
##### Image parameter rules
- A variant is created only when `w`, `h`, `fm`, or a `dpr` other than 1 is present. Other image parameters on their own return the original bytes.
- Image parameters work only on raster images: JPEG, PNG, WebP, GIF, BMP, and TIFF. On any other file they return `415 Transforms supported only for raster images`. SVG and PDF are served as-is.
- At most eight image parameters per request, not counting `v`, `token`, and attribution parameters.
- Each parameter may appear once, and a short name cannot be combined with its alias (`w` with `width`, `fm` with `format`). Either returns `400`.
- Unknown parameters return `400 Unknown params: ...`. A typo fails loudly instead of silently serving the original.
- When both `w` and `h` are set, their ratio must be between 1:50 and 50:1.
- With `fit=cover` and no `fp-x`, `fp-y`, or `focus=auto`, the file's saved focal point is used if one is set. See [Set a focal point](https://steadylink.io/docs/reference/api/platform#set-a-focal-point).
- Each file can have up to 100 distinct image variants and each workspace up to 10,000 by default. A request for a new variant beyond that returns `429` with `asset_transform_cardinality_exceeded` or `workspace_transform_cardinality_exceeded`. Variants that already exist keep working. Use a small, fixed set of sizes rather than arbitrary widths.
##### Delivery responses
| Status | When |
| --- | --- |
| `200` | The file or variant. |
| `206` | A byte range, when the request sends `Range` and asks for the original bytes. Use it for video and resumable downloads. |
| `304` | The request's `If-None-Match` matches the `ETag`. |
| `400` | An invalid, duplicate, conflicting, or unknown parameter. The message names the problem. |
| `403` | `Forbidden`: the file is private and the token is missing, expired, revoked, for another file, or for another revision. `Blocked: infected`: the current revision failed its malware scan. |
| `404` | No such file, a `v` that does not exist, or a file requested through a custom domain that belongs to another workspace. |
| `415` | Image parameters on a file that is not a raster image, or `showcase` on a file that is not an image. |
| `423` | `Scanning`: the revision's malware scan has not finished. Retry after a short wait. |
| `429` | Too many distinct image variants, or too many requests from one client. |
| `451` | The workspace's delivery policy blocks the requester's country. |
##### Delivery response headers
| Header | Value |
| --- | --- |
| `Cache-Control`, `CDN-Cache-Control` | See [Caching](#caching). |
| `ETag` | SHA-256 of the bytes served. For the original, it equals the revision's `hash`. |
| `Content-Type` | The stored type, or the output type of a variant. |
| `Content-Disposition` | `inline` with the stored filename. HTML and SVG files are served as `attachment` (downloaded, not rendered) unless the bucket allows inline rendering. |
| `Accept-Ranges` | `bytes` for original files. |
| `Surrogate-Key`, `Cache-Tag` | `asset-{asset_id}`, for targeted purges at an edge cache. |
| `Content-Security-Policy`, `X-Content-Type-Options` | A strict policy and `nosniff`, so a hosted file cannot run scripts against your visitors. |
#### Caching
The cache lifetime depends on whether the URL follows the current revision or pins one:
| URL | `Cache-Control` | Meaning |
| --- | --- | --- |
| Stable link, no `v` | `public, max-age=300, stale-while-revalidate=3600, stale-if-error=3600, no-transform` | Caches may reuse a response for five minutes, then must check again. |
| Pinned with `?v=` | `public, max-age=31536000, immutable, stale-while-revalidate=604800, stale-if-error=604800, no-transform` | A revision's bytes never change, so caches keep it for a year. |
| Private file | `private, no-store, max-age=0` | Never stored by shared caches, so revoking access takes effect on the next request. |
What this means after a [replacement](https://steadylink.io/docs/reference/api/files#replace-a-file) or [rollback](https://steadylink.io/docs/reference/api/revisions#promote-a-revision): SteadyLink itself switches to the new current revision immediately, but browsers and caches that already hold the stable link may serve the previous bytes until their five minutes are up. `stale-while-revalidate` allows a cache to answer one more request with the old copy while it fetches the new one. Plan on up to about five minutes before every viewer sees a change.
When a page must show a specific revision the moment it ships, link to the pinned URL (`?v=4`) in that release. When a page should always show the latest file, use the stable link and accept the short delay.
#### Signed links
A signed link is a stable link with a `token` parameter that opens a private file until it expires or you revoke it. Each one is backed by a grant record you can list and revoke, so you can share a file with one customer, see which links exist, and cut access off without touching the file.
##### Create a signed link
**POST /api/assets/{asset_id}/signed-url** (scope: `assets:write`)
**Query parameters**
- `ttl` (integer, default 300): Lifetime in seconds. Values are clamped to the range 60 (one minute) to 2,592,000 (30 days).
- `name` (string): Internal label shown when listing grants, such as `Acme review`. Trimmed and cut to 200 characters.
- `revision` (integer): Limit the grant to one revision number. Omit it to follow the current revision. An unknown revision returns `404 Revision not found`.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/signed-url?ttl=86400&name=Acme%20review" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const signed = await steadylink.createSignedLink("3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71", {
ttlSeconds: 86400,
name: "Acme review",
});
console.log(signed.url); // https://cdn.steadylink.io/a/3f2a9c1e-...?token=v2....
```
```python title="Python"
from urllib.parse import urlencode
asset_id = "3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71"
grant = client.request("POST", f"/api/assets/{asset_id}/signed-url?{urlencode({'ttl': 86400, 'name': 'Acme review'})}")
url = f"https://cdn.steadylink.io/a/{asset_id}?{urlencode({'token': grant['token']})}"
```
```bash title="CLI"
steadylink link 3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71 --signed --ttl 86400 --name "Acme review"
```
```json title="Response" status="201 Created"
{
"id": "1c8e5a3f-2d7b-4a9c-b6e1-8f0d2c4a6b97",
"token": "v2.3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71.1791472980.1c8e5a3f-2d7b-4a9c-b6e1-8f0d2c4a6b97.4b1e9f0c7a2d...",
"name": "Acme review",
"revisionNumber": null,
"expiresAt": "2026-10-09T15:23:00.118204"
}
```
Append the token to the delivery URL as `?token=...`. Image parameters can be combined with it, for example `?token=...&w=800&fm=webp`.
Things to know:
- **The token is returned once.** Listing grants does not return tokens. Store the URL if you need to send it again, or create a new grant.
- **Revision grants need `v`.** A grant created with `revision=3` only opens `?v=3&token=...`. The same token without `v`, or with another `v`, returns `403`. The SDK's `signed.url` does not add `v` for you; use `steadylink.link(assetId, { token: signed.token, version: 3 })`.
- **Public files ignore tokens.** A signed link to a public file works, but so does the plain link. Make the file private to restrict it. See [Set file visibility](https://steadylink.io/docs/reference/api/files#set-file-visibility).
- **Bucket grants cover the bucket.** A grant created on a bucket ID also opens every private file in that bucket. Create grants on file asset IDs unless you mean to share the whole bucket.
- **Creating a grant is an expensive operation** and counts toward the expensive-operation limits in [Plan access](https://steadylink.io/docs/reference/api/authentication#plan-access).
##### List signed links
**GET /api/assets/{asset_id}/signed-urls** (scope: `assets:read`)
Returns the 200 most recent grants for a file, newest first, including expired and revoked ones. Tokens are never included.
```bash title="curl"
curl "https://api.steadylink.io/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/signed-urls" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const { items } = await steadylink.listSignedLinks("3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71");
const active = items.filter((grant) => !grant.revokedAt && new Date(grant.expiresAt + "Z") > new Date());
```
```python title="Python"
grants = client.request("GET", "/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/signed-urls")["items"]
```
```json title="Response" status="200 OK"
{
"items": [
{
"id": "1c8e5a3f-2d7b-4a9c-b6e1-8f0d2c4a6b97",
"name": "Acme review",
"revisionNumber": null,
"expiresAt": "2026-10-09T15:23:00.118204",
"revokedAt": null,
"createdAt": "2026-10-08T15:23:00.118204"
}
]
}
```
A grant is active when `revokedAt` is `null` and `expiresAt` is in the future. Timestamps are UTC without an offset.
##### Revoke a signed link
**DELETE /api/assets/{asset_id}/signed-urls/{grant_id}** (scope: `assets:write`)
Stops a signed link before it expires. Private responses are never cached, so the next request with that token returns `403`.
```bash title="curl"
curl -X DELETE "https://api.steadylink.io/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/signed-urls/1c8e5a3f-2d7b-4a9c-b6e1-8f0d2c4a6b97" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.revokeSignedLink("3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71", "1c8e5a3f-2d7b-4a9c-b6e1-8f0d2c4a6b97");
```
```python title="Python"
client.request("DELETE", "/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/signed-urls/1c8e5a3f-2d7b-4a9c-b6e1-8f0d2c4a6b97")
```
```json title="Response" status="200 OK"
{ "revoked": true }
```
A grant ID that does not belong to this file returns `404 Signed access grant not found`. Revoking an already revoked grant succeeds again and updates `revokedAt`.
#### Other delivery tools
##### Get a preview URL
**GET /api/assets/object/{asset_id}/preview** (scope: `assets:read`)
Returns a short-lived URL to view a file without going through public delivery. Previews work for private files without a grant and are not counted in delivery analytics, which makes them right for admin tools and review screens.
**Query parameters**
- `v` (integer): Revision to preview. Omit it for the current revision.
- `thumbnail` (boolean, default false): Return a 480-pixel-wide WebP thumbnail instead of the original. Images only.
```bash title="curl"
curl "https://api.steadylink.io/api/assets/object/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/preview?thumbnail=true" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{ "url": "https://storage-endpoint.example/transforms/e4d7b1a9-.../3b9c....webp?X-Amz-Expires=300&X-Amz-Signature=..." }
```
The URL expires after five minutes; request a new one each time you render. A revision that is not yet clean returns `409 Revision is not ready for preview`. `thumbnail=true` on a non-image returns `415`, and on an image larger than 64 MiB returns `413`.
##### Purge cached image variants
**POST /api/assets/{asset_id}/purge** (scope: `assets:write`)
Forgets every stored image variant of a file, across all its revisions. The next request for each size or format renders it again from the original. You rarely need this, because a new revision always gets fresh variants. It does not clear browser or edge caches.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71/purge" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{ "purged": true }
```
Purging counts as an expensive operation.
#### Next steps
- [Private links](https://steadylink.io/docs/security/private-links): When to use private files and signed links, and how to share them safely.
- [Image transformations guide](https://steadylink.io/docs/guides/image-transformations): Responsive images, art direction, and focal points in practice.
- [Next.js images](https://steadylink.io/docs/guides/nextjs-images): Serve stable links through `next/image` with the SteadyLink loader.
- [How stable links work](https://steadylink.io/docs/concepts/stable-links): Current and pinned revisions, and what changes when you replace a file.
### REST API: Analytics API
Source: https://steadylink.io/docs/reference/api/analytics
Use these routes to build a usage dashboard, alert on error spikes, report which sites send traffic to your files, or find the files that take up the most storage. Every route is read-only and scoped to the workspace that the API key belongs to (or the workspace selected with `X-Workspace-Id` for a signed-in session).
#### Before you start
- **Scope.** API keys need `analytics:read` for every `/api/analytics/*` route. Any workspace member, including a viewer, can read analytics from a signed-in session.
- **Where the numbers come from.** Delivery figures are aggregated from hourly rollups of requests to `https://cdn.steadylink.io/a/{asset_id}` and your delivery domains. A request that has not been rolled up yet does not appear, so expect the current hour to fill in gradually.
- **Time windows are UTC.** `range` accepts `24h`, `7d`, or `30d`. Any other value returns `422`.
| `range` | Window start | Bucket size | Points in a series |
| --- | --- | --- | --- |
| `24h` | Start of the current UTC hour, minus 23 hours | 1 hour | 24 |
| `7d` | Midnight UTC today, minus 6 days | 1 day | 7 |
| `30d` | Midnight UTC today, minus 29 days | 1 day | 30 |
The current hour or day is always the last bucket and is still accumulating.
Percentages in these responses (`cacheHitRate`, `errorRate`, `share`) are expressed from 0 to 100, not as fractions. When there were no requests in the window they are `0`.
#### Summary
##### Get delivery and storage totals
**GET /api/analytics/summary** (scope: `analytics:read`)
Returns one set of totals for the selected window, plus the storage the workspace holds right now. Use it for headline numbers; use the [series](#get-a-time-series) when you need a chart.
**Query parameters**
- `range` (string, default 7d): `24h`, `7d`, or `30d`.
```bash title="curl"
curl "https://api.steadylink.io/api/analytics/summary?range=7d" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```python title="Python"
summary = client.request("GET", "/api/analytics/summary?range=7d")
```
```json title="Response" status="200 OK"
{
"range": "7d",
"requests": 3840000,
"bandwidthBytes": 1840000000000,
"cacheHitRate": 96.8,
"errors": 112,
"actionableDeliveryIssues": {
"missingFileOrRevision": 37
},
"errorRate": 0.0029,
"p95Ms": 18,
"storedBytes": 30494267801,
"objectCount": 128
}
```
**Response fields**
- `requests` (integer): Delivery requests in the window.
- `bandwidthBytes` (integer): Bytes sent to clients in the window.
- `cacheHitRate` (number): Percentage of requests served from cache, rounded to two decimals.
- `errors` (integer): Requests that returned a status of 400 or higher. This includes expected refusals, such as an expired signed link or a private file requested without one.
- `actionableDeliveryIssues.missingFileOrRevision` (integer): Requests for a file or revision that does not exist. This is the error class you can usually fix yourself, for example a link to a deleted file or a pinned revision that was removed.
- `errorRate` (number): `errors` as a percentage of `requests`, rounded to four decimals.
- `p95Ms` (integer): The highest hourly 95th-percentile response time in the window, in milliseconds. It is a worst-hour figure, not a percentile computed across the whole window.
- `storedBytes` (integer): Bytes held by every revision of every file in the workspace, including retained older revisions. Not limited by `range`.
- `objectCount` (integer): Number of files in the workspace. Not limited by `range`.
#### Time series
##### Get a time series
**GET /api/analytics/series** (scope: `analytics:read`)
Returns one point per hour (`24h`) or per day (`7d`, `30d`). The series is always complete: buckets without traffic are filled with zeros, so you can plot `points` directly without checking for gaps. Each `at` value is the start of its bucket in UTC, without a timezone suffix.
**Query parameters**
- `range` (string, default 7d): `24h`, `7d`, or `30d`.
```bash title="curl"
curl "https://api.steadylink.io/api/analytics/series?range=24h" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"range": "24h",
"interval": "hour",
"points": [
{
"at": "2026-10-07T15:00:00",
"requests": 18240,
"bandwidthBytes": 9120044312,
"cacheHits": 17702,
"cacheHitRate": 97.05,
"errors": 3,
"missingFileOrRevision": 1,
"p95Ms": 21
},
{
"at": "2026-10-07T16:00:00",
"requests": 0,
"bandwidthBytes": 0,
"cacheHits": 0,
"cacheHitRate": 0,
"errors": 0,
"missingFileOrRevision": 0,
"p95Ms": 0
}
]
}
```
The example shows two of the 24 points. `interval` is `hour` for `24h` and `day` otherwise.
#### Top files
##### List the most requested files
**GET /api/analytics/objects** (scope: `analytics:read`)
Returns files ordered by request count in the window, most requested first. Files with no delivery requests in the window are not listed.
**Query parameters**
- `range` (string, default 24h): `24h`, `7d`, or `30d`. Note that the default here is `24h`, unlike the other routes.
- `limit` (integer, default 25): Number of files, from 1 to 25. Values outside that range return `422` rather than being clamped.
```bash title="curl"
curl "https://api.steadylink.io/api/analytics/objects?range=7d&limit=10" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"range": "7d",
"limit": 10,
"items": [
{
"assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44",
"bucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10",
"name": "campaign-hero.webp",
"path": "campaign/campaign-hero.webp",
"visibility": "public",
"requests": 412880,
"bandwidthBytes": 98211400192,
"cacheHitRate": 98.41,
"errors": 0,
"missingFileOrRevision": 0,
"p95Ms": 16
}
]
}
```
`visibility` is the file's own setting (`public`, `private`, or `inherit` when it follows the bucket). `bucketId` and `path` are `null` for a file that is no longer in a bucket.
#### Traffic sources
##### List traffic sources
**GET /api/analytics/sources** (scope: `analytics:read`)
Returns where delivery requests came from, grouped by source and ordered by request count. Pass `assetId` to see the sources for one file, for example to find out which site embeds an image.
Each request is classified once, in this order:
1. A tag on the link. `?ref=newsletter` or `?utm_source=newsletter` on a delivery URL makes the source `newsletter` with kind `tagged`. Tags are the only signal that survives apps and email clients that strip the referrer, so add one to links you place in those channels. These parameters never change the bytes that are delivered.
2. A link-preview crawler, such as Discord or Slack fetching a preview card. Kind `preview`.
3. The referring site's host, never its path or query. Well-known platforms are grouped, so `t.co` and `twitter.com` both count as `x.com`. Kinds `search`, `social`, `site`, or `steadylink`.
4. `direct` when nothing identifies the source.
**Query parameters**
- `range` (string, default 7d): `24h`, `7d`, or `30d`.
- `assetId` (uuid): Limit the result to one file.
- `limit` (integer, default 10): Number of sources to return, from 1 to 50. `totalRequests` and `sourceCount` always cover every source, not just the returned ones.
```bash title="curl"
curl "https://api.steadylink.io/api/analytics/sources?range=30d&assetId=3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"range": "30d",
"assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44",
"totalRequests": 52310,
"sourceCount": 14,
"items": [
{ "source": "direct", "kind": "direct", "requests": 30112, "bandwidthBytes": 7180223004, "files": 1, "share": 57.56 },
{ "source": "reddit.com", "kind": "social", "requests": 9844, "bandwidthBytes": 2350118400, "files": 1, "share": 18.82 },
{ "source": "newsletter", "kind": "tagged", "requests": 6020, "bandwidthBytes": 1437312000, "files": 1, "share": 11.51 }
]
}
```
**Item fields**
- `source` (string): Canonical source: a tag, a platform host such as `google.com`, a referring host, or `direct`.
- `kind` (string): `direct`, `search`, `social`, `site`, `tagged`, `preview`, or `steadylink`.
- `files` (integer): Number of distinct files this source requested.
- `share` (number): This source's percentage of `totalRequests`.
#### Activity
##### List recent activity
**GET /api/analytics/activity** (scope: `analytics:read`)
Returns the workspace audit trail, newest first: uploads, new revisions, restores, renames, visibility changes, blocked uploads, and signed-link changes. `range` does not apply here.
**Query parameters**
- `limit` (integer, default 25): Number of events, from 1 to 100.
```bash title="curl"
curl "https://api.steadylink.io/api/analytics/activity?limit=50" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"items": [
{
"id": "c41e8a20-77b5-4f0e-9a61-0e2d4b8c3f19",
"title": "New revision uploaded",
"action": "asset.object_version_create",
"assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44",
"assetName": "campaign-hero.webp",
"actor": null,
"success": true,
"metadata": { "version": 4 },
"createdAt": "2026-10-08T09:12:44.381022"
}
]
}
```
`title` is a readable label for known actions and a title-cased form of `action` otherwise, so match on `action` in code. `actor` is `"You"` when the signed-in user performed the action and `null` in every other case, including every request made with an API key. `metadata` varies by action.
#### Storage
##### Get storage by bucket and file
**GET /api/analytics/storage** (scope: `analytics:read`)
Returns how much storage each bucket holds, down to each file's current revision and all of its retained revisions. Use it to find files whose revision history is worth trimming. Buckets are sorted largest first; files inside a bucket are sorted by `allVersionsBytes`, largest first.
```bash title="curl"
curl "https://api.steadylink.io/api/analytics/storage" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"usedBytes": 30494267801,
"limitBytes": 107374182400,
"remainingBytes": 76879914599,
"buckets": [
{
"bucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10",
"name": "Marketing",
"bytes": 21877391360,
"fileCount": 84,
"files": [
{
"assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44",
"name": "launch-video.mp4",
"path": "video/launch-video.mp4",
"currentBytes": 412090368,
"allVersionsBytes": 1648361472,
"versionCount": 4
}
]
}
]
}
```
**Response fields**
- `limitBytes` (integer): The storage allowance of the workspace's active plan. A paid plan whose billing is not active counts as the free plan here.
- `buckets[].bytes` (integer): Sum of `allVersionsBytes` for the files in the bucket.
- `files[].currentBytes` (integer): Size of the revision the link serves now.
- `files[].allVersionsBytes` (integer): Size of every retained revision of the file, current included.
The response lists every file in every bucket, so it can be large for big workspaces. Cache it rather than polling it.
#### Next steps
- [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits): How storage, delivery, and API allowances are counted.
- [Revisions API](https://steadylink.io/docs/reference/api/revisions): Delete retained revisions you found in the storage report.
- [Collections and portals](https://steadylink.io/docs/reference/api/sharing#get-portal-analytics): Views and downloads for a shared portal.
### REST API: Workspaces API
Source: https://steadylink.io/docs/reference/api/workspaces
A workspace owns every bucket, file, member, and API key. Use these routes to check which workspace a key belongs to and what its plan allows, or to build internal admin tooling that manages members and keys.
#### Who can call what
Most routes on this page change who has access to the workspace, so they deliberately accept only a signed-in person, not an API key. An API key cannot create other keys, invite people, or change roles, even if it has the `workspace:admin` scope. Those requests pass the scope check and then fail with `403` and `"code": "insufficient_workspace_permission"`.
| Route | API key | Signed-in session |
| --- | --- | --- |
| `GET /api/workspaces`, `GET /api/workspaces/current`, `GET /api/workspaces/current/members` | `workspace:read` | Any member |
| `GET /api/workspaces/current/api-entitlement` | `workspace:read` | Owner or admin |
| `PATCH /api/workspaces/current`, members, invitations, API keys | Not allowed | Owner or admin |
| `POST /api/workspaces`, accept an invitation | Not allowed | Any signed-in user |
| `DELETE /api/workspaces/current`, transfer ownership | Not allowed | Owner only |
A signed-in session sends `Authorization: Bearer `. If the person belongs to more than one workspace, also send `X-Workspace-Id`; without it the request fails with `400` and `"code": "workspace_required"`. API keys are bound to one workspace and do not need the header. Sending a different workspace ID with a key returns `403`. See [Authentication](https://steadylink.io/docs/reference/api/authentication) for both methods.
Admins can manage members, viewers, and invitations, but only the owner can promote, demote, invite, or remove an admin.
#### The workspace object
```json title="Workspace"
{
"id": "6e0b2f7a-1c94-4d3b-a8e5-2f71c0d9b6a3",
"name": "Northwind Studio",
"pictureUrl": null,
"role": "admin",
"permissions": ["analytics:read", "assets:read", "assets:write", "billing:manage", "conversions:run", "workspace:admin", "workspace:read"],
"bucketCount": 6,
"encryptionEnabled": false,
"settings": {
"defaultVisibility": "private",
"requireCleanDelivery": true,
"accentTheme": "steadylink"
},
"plan": "pro",
"billingStatus": "active"
}
```
**Fields**
- `role` (string): The caller's role: `owner`, `admin`, `member`, or `viewer`. For an API key it is `api_key`, and `permissions` is empty because a key's access comes from its scopes.
- `permissions` (string[]): What the caller's role allows in this workspace.
- `bucketCount` (integer): Number of buckets. Only `GET /api/workspaces` counts them; the single-workspace routes always return `0`.
- `encryptionEnabled` (boolean): Whether newly committed originals are encrypted. See [Encryption](https://steadylink.io/docs/reference/api/platform#encryption-of-originals).
- `settings` (object): Workspace defaults. `defaultVisibility` (default `private`) and `requireCleanDelivery` (default `true`) are always present. The object can also hold keys managed by other routes, such as `deliveryPolicy`.
- `plan` (string): Plan identifier, for example `hobby`, `personal`, `pro`, or `business`.
#### Workspaces
##### List workspaces
**GET /api/workspaces** (scope: `workspace:read`)
For a signed-in user, returns every workspace they belong to, sorted by name. For an API key, returns only the key's workspace.
```bash title="curl"
curl https://api.steadylink.io/api/workspaces \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"items": [
{
"id": "6e0b2f7a-1c94-4d3b-a8e5-2f71c0d9b6a3",
"name": "Northwind Studio",
"role": "api_key",
"permissions": [],
"bucketCount": 6,
"...": "other workspace fields"
}
]
}
```
##### Get the current workspace
**GET /api/workspaces/current** (scope: `workspace:read`)
Returns the workspace the key belongs to, or the one selected with `X-Workspace-Id`. A quick way to confirm which workspace a key points at before running a script against it.
```bash title="curl"
curl https://api.steadylink.io/api/workspaces/current \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
The response is a [workspace object](#the-workspace-object) with status `200 OK`.
##### Get the API entitlement
**GET /api/workspaces/current/api-entitlement** (scope: `workspace:read`)
Returns whether developer API access is active for the workspace, why not if it is not, and the limits that apply to every key in the workspace. All keys share these limits; creating more keys does not add capacity.
A signed-in owner or admin can read this even while API access is paused, which is how the dashboard explains a paused state. An API key cannot: while access is paused, every key request, including this one, fails with `403` before it reaches the route.
```bash title="curl"
curl https://api.steadylink.io/api/workspaces/current/api-entitlement \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"enabled": true,
"reason": null,
"plan": "pro",
"billingStatus": "active",
"currentPeriodEnd": "2026-11-01T00:00:00",
"limits": {
"readRequestsPerMinute": 1200,
"writeRequestsPerMinute": 300,
"burstRequestsPer10Seconds": 300,
"expensiveOperationsPerMinute": 120,
"expensiveOperationsPerDay": 25000,
"requestsPerMonth": 2000000,
"activeApiKeys": 10,
"maxUploadBytes": 5368709120
},
"activeApiKeys": 2
}
```
**Response fields**
- `enabled` (boolean): Whether API keys work for this workspace right now.
- `reason` (string | null): Why access is off: `subscription_inactive` (a paid plan whose billing is not active or whose period has ended), `paid_plan_required`, or `api_access_disabled` (turned off for the workspace by SteadyLink). `null` when enabled.
- `limits` (object | null): Per-plan limits for the whole workspace. `activeApiKeys` inside `limits` is the maximum; the top-level `activeApiKeys` is how many unrevoked, unexpired keys exist now.
The free plan includes API access with one active key and 50,000 requests a month. For how these limits are applied and what a `429` looks like, see [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits).
##### Update workspace settings
**PATCH /api/workspaces/current**
Changes the workspace name, settings, or both. Send only what you want to change. Settings you omit keep their current values.
**Body**
- `name` (string): New name, 1 to 200 characters.
- `settings.defaultVisibility` (string): `public` or `private`. The default visibility for new uploads. A bucket can override it. Workspaces with no stored value behave as `private`.
- `settings.requireCleanDelivery` (boolean): A stored workspace preference. Any value is saved as a boolean.
- `settings.accentTheme` (string): Dashboard accent: `steadylink`, `lime`, `iris`, `sunset`, or `sky`.
```bash title="curl"
curl -X PATCH https://api.steadylink.io/api/workspaces/current \
-H "Authorization: Bearer $STEADYLINK_ACCESS_TOKEN" \
-H "X-Workspace-Id: 6e0b2f7a-1c94-4d3b-a8e5-2f71c0d9b6a3" \
-H "Content-Type: application/json" \
-d '{ "name": "Northwind Studio", "settings": { "defaultVisibility": "public" } }'
```
The response is the updated [workspace object](#the-workspace-object). Any other key inside `settings` returns `422` with `"code": "invalid_settings"` and the rejected keys in `invalid`.
##### Create a workspace
**POST /api/workspaces**
Creates a workspace on the free plan with the caller as owner. Send `{ "name": "Client portal" }`. Returns `201 Created` with the new [workspace object](#the-workspace-object).
##### Delete the current workspace
**DELETE /api/workspaces/current**
Permanently deletes the workspace, every file and revision in it, and its stored bytes. To confirm, the body must repeat the exact workspace name: `{ "name": "Northwind Studio" }`. A mismatch returns `422`.
You cannot delete your only workspace. Create another first, or the request fails with `409` and `"code": "last_workspace"`. On success the response names the workspace to switch to:
```json title="Response" status="200 OK"
{ "deleted": true, "nextWorkspaceId": "b8f41d2c-5e60-4a17-93cb-0c2e7d4f1a85" }
```
**Warning.** Deletion cannot be undone. Every stable link in the workspace stops working.
#### Members
##### List members
**GET /api/workspaces/current/members** (scope: `workspace:read`)
```bash title="curl"
curl https://api.steadylink.io/api/workspaces/current/members \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"items": [
{ "userId": "0d9e3a71-48c2-4b5f-a1e6-7c2b9f0e4d38", "name": "Ana Ruiz", "email": "ana@northwind.example", "role": "admin", "avatarUrl": null },
{ "userId": "5b7c1f20-93ad-4e8b-b6f4-2a0d8e1c7f95", "name": "Sam Lee", "email": "sam@northwind.example", "role": "member", "avatarUrl": null }
]
}
```
##### Change a member's role
**PATCH /api/workspaces/current/members/{user_id}**
**Body**
- `role` (string): `admin`, `member`, or `viewer`.
Returns the updated member. The owner's role cannot be changed this way (`409`); [transfer ownership](#transfer-ownership) instead. An admin who tries to change an admin, or to make someone an admin, gets `403`.
##### Remove a member
**DELETE /api/workspaces/current/members/{user_id}**
Removes the person's access immediately and returns `{ "removed": true }`. The owner cannot be removed (`409`), and only the owner can remove an admin.
##### Transfer ownership
**POST /api/workspaces/current/ownership**
Makes another member the owner and turns the current owner into an admin. Send `{ "userId": "0d9e3a71-48c2-4b5f-a1e6-7c2b9f0e4d38" }`. Returns `{ "transferred": true, "ownerUserId": "..." }`.
#### Invitations
##### Invite a member
**POST /api/workspaces/current/invitations**
Emails an invitation link to the address. The invitation reserves a seat until it is accepted, revoked, or expires.
**Body**
- `email` (string): The invitee's email address. It is matched case-insensitively when they accept.
- `role` (string, default member): `admin`, `member`, or `viewer`. Only the owner can invite an admin.
- `expiresInDays` (integer, default 7): From 1 to 30.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/workspaces/current/invitations \
-H "Authorization: Bearer $STEADYLINK_ACCESS_TOKEN" \
-H "X-Workspace-Id: 6e0b2f7a-1c94-4d3b-a8e5-2f71c0d9b6a3" \
-H "Content-Type: application/json" \
-d '{ "email": "jo@agency.example", "role": "viewer", "expiresInDays": 14 }'
```
```json title="Response" status="201 Created"
{
"id": "e2a7c9d4-0b18-4f63-9c5e-71d4a3b8f026",
"email": "jo@agency.example",
"role": "viewer",
"status": "pending",
"expiresAt": "2026-10-22T10:04:31.118204",
"createdAt": "2026-10-08T10:04:31.118204",
"token": "q8Vw...",
"acceptPath": "/api/workspaces/invitations/q8Vw.../accept"
}
```
`token` is returned only in this response. Inviting the same address again revokes the earlier pending invitation. Errors: `409` if the person is already a member, `409` with `"code": "seat_limit_reached"` if members plus pending invitations already fill the plan's seats.
##### List invitations
**GET /api/workspaces/current/invitations**
Returns every invitation, newest first, with `status` of `pending`, `accepted`, `revoked`, or `expired`. Tokens are never included.
##### Revoke an invitation
**DELETE /api/workspaces/current/invitations/{invitation_id}**
Returns `{ "revoked": true }` and frees the reserved seat.
##### Accept an invitation
**POST /api/workspaces/invitations/{token}/accept**
The signed-in user's email must match the invitation. Returns `{ "accepted": true, "workspaceId": "...", "role": "viewer" }`. Errors: `404` if the invitation is not pending, `410` if it expired, `403` if it belongs to another email address.
#### API keys
##### List API keys
**GET /api/workspaces/current/api-keys**
Returns metadata for every key, newest first, including revoked and expired keys. The raw key value is never returned after creation.
```json title="Response" status="200 OK"
{
"items": [
{
"id": "a3c5e7f9-1b2d-4e6f-8a0c-2e4f6a8c0b1d",
"name": "production uploader",
"scopes": ["assets:read", "assets:write"],
"createdAt": "2026-09-14T08:30:00.000000",
"lastUsedAt": "2026-10-08T09:55:12.402113",
"expiresAt": "2026-12-13T08:30:00.000000",
"revokedAt": null
}
]
}
```
`lastUsedAt` is updated at most every five minutes, so treat it as approximate.
##### Create an API key
**POST /api/workspaces/current/api-keys**
**Body**
- `name` (string): A label you will recognize later, 1 to 200 characters.
- `scopes` (string[]): Any of `assets:read`, `assets:write`, `analytics:read`, `workspace:read`, `workspace:admin`. Grant only what the integration needs.
- `expiresInDays` (integer): From 1 to 365. Omit for a key that does not expire.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/workspaces/current/api-keys \
-H "Authorization: Bearer $STEADYLINK_ACCESS_TOKEN" \
-H "X-Workspace-Id: 6e0b2f7a-1c94-4d3b-a8e5-2f71c0d9b6a3" \
-H "Content-Type: application/json" \
-d '{ "name": "production uploader", "scopes": ["assets:read", "assets:write"], "expiresInDays": 90 }'
```
```json title="Response" status="201 Created"
{
"id": "a3c5e7f9-1b2d-4e6f-8a0c-2e4f6a8c0b1d",
"name": "production uploader",
"scopes": ["assets:read", "assets:write"],
"createdAt": "2026-10-08T10:12:00.000000",
"lastUsedAt": null,
"expiresAt": "2027-01-06T10:12:00.000000",
"revokedAt": null,
"key": "slk_..."
}
```
**Warning.** `key` appears only in this response. SteadyLink stores only a hash, so a lost key cannot be recovered; revoke it and create a new one.
Errors:
| Status | Code | Meaning |
| --- | --- | --- |
| `403` | `subscription_inactive`, `paid_plan_required`, `api_access_disabled` | API access is not active for the workspace. |
| `409` | `api_key_limit_reached` | The plan's active key limit is reached. Revoke an unused key first. |
| `422` | `invalid_scopes` | An unknown scope, or an empty list. The response lists `allowed` scopes. |
Most people create keys in **Dashboard > Developers** instead. See [API keys](https://steadylink.io/docs/security/api-keys) for scope choices and rotation.
##### Revoke an API key
**DELETE /api/workspaces/current/api-keys/{key_id}**
Revokes the key immediately and returns `{ "revoked": true }`. Requests that use it fail with `401` from then on. Revoked keys stay in the list with `revokedAt` set.
#### Next steps
- [Workspaces and roles](https://steadylink.io/docs/concepts/workspaces-and-roles): What each role can do, and how workspaces isolate data.
- [API keys](https://steadylink.io/docs/security/api-keys): Choosing scopes, rotating keys, and keeping them out of browsers.
- [Authentication](https://steadylink.io/docs/reference/api/authentication): Headers, scopes, and rate-limit responses.
### REST API: Conversions API
Source: https://steadylink.io/docs/reference/api/conversions
Use these routes to convert a file you upload, or a file already stored in a workspace, into another format. A conversion is a temporary job: you create it, send the source bytes, start it, poll until it completes, and then download the result or save it into a bucket as a normal file. For a dashboard walkthrough, see [File conversions](https://steadylink.io/docs/guides/file-conversions).
#### How a conversion job works
**Create the job**
`POST /api/conversions` checks the format pair, options, size, and your limits, then returns the job with a presigned `uploadUrl`. If you convert a stored file instead, there is nothing to upload and the job is queued immediately.
**Upload the source**
`PUT` the exact bytes to `uploadUrl` with `Content-Type: application/octet-stream`. The URL only accepts the size you declared.
**Start it**
`POST /api/conversions/{job_id}/start` confirms the upload and queues the job.
**Poll and collect**
`GET /api/conversions/{job_id}` until `status` is `complete` or `failed`, then call `/download` or `/save`.
A job moves through `created`, `uploaded`, `queued`, `processing`, and then one of `complete`, `failed`, `cancelled`, or `expired`. `progress` (0 to 100), `stage`, and `stageDetail` describe where it is.
Jobs and their files are deleted when `expiresAt` passes. Any request for an expired job returns `410`. Download or save the result before then.
#### Access: anonymous, session, or API key
The same routes serve three kinds of caller, and the job remembers which one created it.
| Caller | How it authenticates | Who can read the job later |
| --- | --- | --- |
| Anonymous | No credentials | Anyone holding the job's `accessToken`, sent as `X-Conversion-Token` |
| Signed-in user | `Authorization: Bearer` (and `X-Workspace-Id` if they belong to several workspaces) | That user |
| API key | `X-API-Key` | That exact key. Another key in the same workspace gets `403`. |
- **Anonymous jobs** get an `accessToken` in the create response, and only there. Store it with the job ID; without it the job cannot be read, started, or downloaded.
- **API keys** need `assets:write` to create, start, upload, save, archive, or cancel jobs, and `assets:read` to read a job, download it, or list recent jobs. Authenticated jobs never return an `accessToken`.
- **Signed-in viewers** cannot create conversions, because conversions use workspace credits (`403`, `"permission": "conversions:run"`). Saving a result needs a role that can write files.
Authenticated jobs count against the workspace's conversion allowance and run in a faster queue than anonymous jobs. `serviceTier` on the job shows which queue it uses: `anonymous`, `member`, or `priority`.
#### Catalog and limits
##### Get the catalog
**GET /api/conversions/catalog**
Returns every supported format group and the limits for anonymous callers and each plan. Read it at startup instead of hardcoding formats or sizes; limits can change without a code release.
```bash title="curl"
curl https://api.steadylink.io/api/conversions/catalog
```
```json title="Response" status="200 OK"
{
"groups": [
{
"id": "image",
"label": "Images",
"engine": "image",
"inputs": ["jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "bmp", "heic", "ico", "svg"],
"outputs": ["jpg", "png", "webp", "avif", "gif", "tiff", "bmp", "ico"],
"weight": 1
}
],
"retentionHours": 1,
"anonymous": {
"plan": "anonymous",
"concurrency": 2,
"maxBytes": 26214400,
"videoMaxBytes": 15728640,
"monthlyUnits": null,
"dailyJobs": 10,
"retentionHours": 1,
"videoMaxDurationSeconds": 120,
"videoMaxWidth": 1920,
"videoMaxHeight": 1080,
"videoConcurrency": 1,
"videoMonthlyUnits": null
},
"registered": { "plan": "hobby", "maxBytes": 104857600, "priorityQueue": false, "...": "same fields" },
"plans": {
"hobby": { "maxBytes": 104857600, "retentionHours": 24, "priorityQueue": false, "...": "same fields" },
"pro": { "maxBytes": 524288000, "retentionHours": 48, "priorityQueue": true, "...": "same fields" }
}
}
```
The example is shortened. Supported groups:
| Group | Inputs | Outputs |
| --- | --- | --- |
| Images | jpg, png, webp, avif, gif, tiff, bmp, heic, ico, svg | jpg, png, webp, avif, gif, tiff, bmp, ico |
| Documents | doc, docx, odt, rtf, txt, html | pdf, docx, odt, rtf, txt, html |
| Spreadsheets | xls, xlsx, ods, csv | pdf, xlsx, ods, csv |
| Presentations | ppt, pptx, odp | pdf, pptx, odp |
| PDF | pdf | jpg, png, txt, docx, pdf |
| Audio | mp3, wav, aac, flac, ogg, m4a, opus | the same seven |
| Video | mp4, webm, mov, avi, mkv | mp4, webm, mov, avi, mkv, gif, mp3 |
| Archives | zip, tar, tar.gz, 7z | zip, tar, tar.gz, 7z |
| Ebooks | epub, mobi, azw3, pdf, txt | epub, mobi, azw3, pdf, txt |
Format names are case-insensitive and may start with a dot. `jpeg` is read as `jpg`, `tif` as `tiff`, and `tgz` as `tar.gz`. Conversion is only possible within a group; a pair that no group supports returns `422`.
Default limits by caller (the catalog is authoritative):
| Caller | Max file | Max video | Jobs at once | Kept for |
| --- | --- | --- | --- | --- |
| Anonymous | 25 MB | 15 MB | 2, and 10 jobs per 24 hours | 1 hour |
| Hobby | 100 MB | 50 MB | 2 | 24 hours |
| Personal | 250 MB | 100 MB | 2 | 24 hours |
| Pro | 500 MB | 250 MB | 4 | 48 hours |
| Business | 1 GB | 500 MB | 6 | 72 hours |
#### Jobs
##### Create a conversion
**POST /api/conversions** (scope: `assets:write`)
Send either `filename` and `size` for a new upload, or `sourceAssetId` to convert a stored file. The scope applies when you call with an API key; anonymous callers send no credentials.
**Body**
- `inputFormat` (string): Source format, for example `png`.
- `outputFormat` (string): Target format, for example `avif`.
- `filename` (string): Source file name, up to 512 characters. Required unless `sourceAssetId` is set.
- `size` (integer): Exact source size in bytes. Required unless `sourceAssetId` is set. The upload must match it.
- `contentType` (string): Source MIME type, recorded with the job.
- `sourceAssetId` (uuid): Convert a file already in the workspace. Requires a session or API key.
- `sourceVersion` (integer): Revision number of `sourceAssetId` to convert. Defaults to the current revision.
- `options` (object): Conversion options. See below. Unknown keys return `422`.
**Options**
- `width` (integer): Target width, 1 to 16384. Out-of-range values return `422`.
- `height` (integer): Target height, 1 to 16384.
- `quality` (integer): 1 to 100. Values outside the range are clamped.
- `fps` (integer): Frame rate for video and animated output, 1 to 30. Clamped.
- `background` (string): Background color value, up to 32 characters.
- `effort` (string): Encoder effort: `fast`, `balanced`, or `maximum`.
Upload a new file:
```bash title="curl"
curl -X POST https://api.steadylink.io/api/conversions \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"filename": "campaign.png",
"size": 2841024,
"inputFormat": "png",
"outputFormat": "avif",
"contentType": "image/png",
"options": { "quality": 82, "width": 1200 }
}'
```
```json title="Response" status="201 Created"
{
"id": "7d4e2b19-8a3c-4f50-b6e1-0c9a2d5f8e73",
"status": "created",
"category": "image",
"inputFormat": "png",
"outputFormat": "avif",
"sourceName": "campaign.png",
"sourceBytes": 2841024,
"outputName": null,
"outputBytes": null,
"outputMime": null,
"progress": 0,
"stage": "created",
"stageDetail": "Waiting for upload",
"serviceTier": "priority",
"timings": {},
"error": null,
"expiresAt": "2026-10-10T10:20:00.000000",
"savedAssetId": null,
"saveUploadSessionId": null,
"registered": true,
"createdAt": "2026-10-08T10:20:00.000000",
"uploadUrl": ""
}
```
An anonymous caller gets the same response plus `"accessToken"` and `"registered": false`.
Then upload the bytes and start the job:
```bash title="curl"
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/octet-stream" \
--data-binary @campaign.png
```
Convert a stored file instead. The job starts as `queued` with no `uploadUrl`, so skip the upload and start steps:
```bash title="curl"
curl -X POST https://api.steadylink.io/api/conversions \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sourceAssetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44",
"inputFormat": "png",
"outputFormat": "avif",
"options": { "quality": 82, "width": 1200 }
}'
```
A stored source must have passed the malware scan and must not be encrypted (`409` otherwise), and `inputFormat` must match its file extension (`422` otherwise).
Errors:
| Status | Code | Meaning |
| --- | --- | --- |
| `413` | `conversion_size_limit`, `video_size_limit` | The source is larger than the caller's limit. |
| `422` | none | Unsupported format pair, invalid option, or missing `filename` and `size`. |
| `429` | `conversion_daily_limit` | Anonymous caller reached 10 jobs in 24 hours. |
| `429` | `conversion_concurrency_limit`, `video_concurrency_limit` | Too many jobs are active. Wait for one to finish. |
##### Start a conversion
**POST /api/conversions/{job_id}/start** (scope: `assets:write`)
Checks that the uploaded object exists and matches `size`, then queues the job. Calling it again on a job that is already queued, processing, or complete returns the job unchanged, so it is safe to retry.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/conversions/7d4e2b19-8a3c-4f50-b6e1-0c9a2d5f8e73/start \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
The response is the job with `"status": "queued"` and status `202 Accepted`. Errors: `400` when the upload is missing or its size differs (the reserved credits are released), `409` when the job was cancelled or expired.
##### Upload through the API
**PUT /api/conversions/{job_id}/upload** (scope: `assets:write`)
A fallback for clients that cannot reach the storage URL, such as a browser blocked by a network policy. Stream the raw bytes as the request body; `Content-Length`, if sent, must equal `size`. Returns the job with `"status": "uploaded"`. Then call start as usual. Prefer `uploadUrl` when you can, since it does not pass the bytes through the API.
##### Get a conversion
**GET /api/conversions/{job_id}** (scope: `assets:read`)
Returns the job. Poll every one to two seconds while it is queued or processing.
```bash title="curl"
curl https://api.steadylink.io/api/conversions/7d4e2b19-8a3c-4f50-b6e1-0c9a2d5f8e73 \
-H "X-Conversion-Token: $CONVERSION_TOKEN"
```
```json title="Response" status="200 OK"
{
"id": "7d4e2b19-8a3c-4f50-b6e1-0c9a2d5f8e73",
"status": "complete",
"progress": 100,
"stage": "complete",
"outputName": "campaign.avif",
"outputBytes": 184312,
"outputMime": "image/avif",
"error": null,
"expiresAt": "2026-10-08T11:20:00.000000",
"registered": false,
"...": "other job fields"
}
```
When a job fails, `error` holds `code` and `message`. Errors: `403` without the right identity or token, `404` for an unknown ID, `410` once expired.
##### Download the result
**GET /api/conversions/{job_id}/download** (scope: `assets:read`)
Returns a presigned URL for the converted file. It is JSON, not a redirect: fetch `url` within 15 minutes. Returns `409` until the job is complete.
```bash title="curl"
curl https://api.steadylink.io/api/conversions/7d4e2b19-8a3c-4f50-b6e1-0c9a2d5f8e73/download \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```json title="Response" status="200 OK"
{
"url": "",
"filename": "campaign.avif",
"expiresIn": 900
}
```
##### Download several results as an archive
**POST /api/conversions/archive** (scope: `assets:write`)
Bundles 2 to 25 completed jobs into one `zip` or `tar.gz` file. Each item needs the same access as a single download, so anonymous items include their token. Duplicate output names are renamed (`photo.avif`, `photo-2.avif`). The combined output may be at most 1 GB (`413` otherwise).
```bash title="curl"
curl -X POST https://api.steadylink.io/api/conversions/archive \
-H "Content-Type: application/json" \
-d '{
"format": "zip",
"items": [
{ "id": "7d4e2b19-8a3c-4f50-b6e1-0c9a2d5f8e73", "token": "Jx3..." },
{ "id": "1c8f5a27-3d9e-4b06-a2f4-8e7b0d6c9a51", "token": "Pq9..." }
]
}'
```
The response has the same shape as a single download, with `filename` set to `steadylink-conversions.zip` or `steadylink-conversions.tar.gz`. Returns `409` if any job is not complete.
##### Save the result to a bucket
**POST /api/conversions/{job_id}/save** (scope: `assets:write`)
Copies the converted file into a bucket. It then goes through the same finalization and scanning as any upload and gets its own stable link. Requires a session or API key with a workspace.
**Body**
- `bucketId` (uuid): Destination bucket in the caller's workspace.
- `path` (string): Folder inside the bucket, for example `converted/`. `..` segments are rejected.
- `filename` (string): Name for the saved file. Defaults to `outputName`.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/conversions/7d4e2b19-8a3c-4f50-b6e1-0c9a2d5f8e73/save \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "bucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10", "path": "converted/", "filename": "campaign.avif" }'
```
The response is the job with `saveUploadSessionId` set and status `202 Accepted`. Saving happens in the background; once it finishes, `savedAssetId` holds the new file's asset ID. Saving twice returns the job without creating a second file.
An anonymous job can be claimed by someone who signs in afterward: call save with their session and the job's `X-Conversion-Token`. The job then belongs to them. A job that already belongs to another user or key returns `403`.
##### Cancel or delete a conversion
**DELETE /api/conversions/{job_id}** (scope: `assets:write`)
Cancels a job that has not finished and releases its reserved credits. For a finished job, it expires the job immediately so its files are removed. Both return `{ "cancelled": true }`.
##### List recent conversions
**GET /api/conversions/recent** (scope: `assets:read`)
Returns unexpired jobs that have not failed or been cancelled, newest first, for the calling user or key. `limit` defaults to 50, up to 100. Anonymous callers get `401`.
##### List convertible workspace files
**GET /api/conversions/sources** (scope: `assets:read`)
Lists stored files that can be used as `sourceAssetId`: scanned clean and not encrypted. Supports `q` (matches file name, path, or bucket name), `limit` (1 to 50, default 25), and `cursor` (the `nextCursor` from the previous page). Each item includes `assetId`, `name`, `size`, `contentType`, `version`, `bucketId`, `bucketName`, `path`, and `modifiedAt`.
#### Next steps
- [File conversions guide](https://steadylink.io/docs/guides/file-conversions): Convert files from the dashboard and save them to a bucket.
- [Image transformations](https://steadylink.io/docs/guides/image-transformations): Resize and reformat images on delivery without creating new files.
- [Errors](https://steadylink.io/docs/reference/errors): Error bodies and when to retry.
### REST API: Requests and migration API
Source: https://steadylink.io/docs/reference/api/requests
These routes automate the review-oriented workflows: asking someone to replace one file, collecting several files through a request form, publishing a revision at a set time, and moving an existing website's media into SteadyLink. Each section lists the management routes your integration calls, then the public routes the recipient's browser calls.
#### Access model
- **Management routes** accept a signed-in session or an API key. With a key, `GET` routes need `assets:read` and every other method needs `assets:write`. Signed-in viewers can read but not change anything.
- **Public routes** are authorized only by the secret token in the URL. They never see your API key, so you can hand the link to an agency, a client, or a freelancer. Treat the token like a password: anyone with the link can upload until it expires or you revoke it.
- **Tokens are shown once.** Creating a replacement request or a request form returns its token a single time. If you lose a replacement request's link, [rotate it](#rotate-a-replacement-link); request forms can [rotate](#rotate-a-request-form-link) too, and their list response also includes the current link.
- **Nothing goes live without review.** Files uploaded through public routes sit in temporary storage until someone in the workspace approves them. Rejecting or revoking deletes the temporary bytes.
#### Replacement requests
A replacement request lets one person upload a new version of one existing file without an account. When you approve the upload, it becomes a new revision of that file, so its stable link starts serving the new bytes. The recipient opens `https://steadylink.io/replace/{token}`.
Statuses: `pending` (waiting for an upload), `submitted` (waiting for your decision), `approved`, `scheduled` (approved for a future publish time), `rejected`, and `revoked`.
##### Create a replacement request
**POST /api/assets/{asset_id}/replacement-requests** (scope: `assets:write`)
The file must be in a bucket (`409` otherwise).
**Body**
- `recipientName` (string): Shown to the recipient, up to 200 characters.
- `note` (string): Instructions shown to the recipient, up to 2000 characters.
- `expiresInDays` (integer, default 7): From 1 to 30.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/assets/3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44/replacement-requests \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "recipientName": "Agency producer", "note": "Upload the approved hero image", "expiresInDays": 7 }'
```
```json title="Response" status="200 OK"
{
"id": "4b9e1d73-2c8a-4f05-b7e6-9a3d0c1f5e82",
"assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44",
"recipientName": "Agency producer",
"note": "Upload the approved hero image",
"status": "pending",
"filename": null,
"byteSize": null,
"mime": null,
"expiresAt": "2026-10-15T10:30:00.000000",
"submittedAt": null,
"decidedAt": null,
"createdAt": "2026-10-08T10:30:00.000000",
"token": "Zt7c...",
"sharePath": "/replace/Zt7c..."
}
```
Send the recipient `https://steadylink.io` followed by `sharePath`.
##### List replacement requests for a file
**GET /api/assets/{asset_id}/replacement-requests** (scope: `assets:read`)
Returns the 50 most recent requests for the file, newest first, in the same shape as above without `token`.
##### List replacement requests in the workspace
**GET /api/assets/replacement-requests** (scope: `assets:read`)
Returns the 200 most recent requests across the workspace, each with an extra `assetName`. Use it to build a review queue: filter for `"status": "submitted"`.
##### Approve a replacement
**POST /api/assets/{asset_id}/replacement-requests/{request_id}/approve** (scope: `assets:write`)
Turns the submitted upload into a new revision of the file. With an empty body, the new revision is published immediately.
**Body**
- `publishAt` (datetime): Publish the new revision at this time instead of now. The file keeps serving its current revision until then.
- `rollbackAt` (datetime): Switch back to the revision that was current before this approval at this time. Must be after `publishAt` when both are set.
Times are ISO 8601. Include an offset or `Z`; a time without one is read as UTC. A schedule must be at least 10 seconds in the future and within one year (`422` otherwise).
```bash title="curl"
curl -X POST https://api.steadylink.io/api/assets/3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44/replacement-requests/4b9e1d73-2c8a-4f05-b7e6-9a3d0c1f5e82/approve \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "publishAt": "2026-11-01T14:00:00Z", "rollbackAt": "2026-11-08T14:00:00Z" }'
```
```json title="Response" status="200 OK"
{ "approved": true, "version": 5, "scheduledJobId": "e81f3c42-7a5d-4b90-8c16-3f2e9d0a7b54" }
```
`version` is the new revision number. `scheduledJobId` is set when you passed `publishAt`; cancel it with [Cancel a scheduled revision](#cancel-a-scheduled-revision). Returns `409` if the request is not `submitted`.
##### Reject a replacement
**POST /api/assets/{asset_id}/replacement-requests/{request_id}/reject** (scope: `assets:write`)
Deletes the uploaded candidate and closes the request. The public link stops working (`410`). Returns `{ "rejected": true }`, or `409` if nothing is awaiting a decision. To let the recipient try again, create a new request.
##### Rotate a replacement link
**POST /api/assets/{asset_id}/replacement-requests/{request_id}/rotate-link** (scope: `assets:write`)
Issues a new token for a `pending` or `submitted` request that has not expired, and returns the request with the new `token` and `sharePath`. The old link stops working immediately (`404`). Use it when a link was sent to the wrong person or you lost the original.
##### Revoke a replacement request
**DELETE /api/assets/{asset_id}/replacement-requests/{request_id}** (scope: `assets:write`)
Closes the request in any state and deletes any uploaded candidate. Returns `{ "revoked": true }`.
##### Recipient: read the request
**GET /api/replacement-requests/{token}**
Returns what the recipient needs to see: the request fields, `assetName`, `workspaceName`, and `canUpload` (true while `pending` or `submitted`). Returns `404` for an unknown token and `410` once the request has expired, been rejected, or been revoked.
##### Recipient: get an upload URL
**POST /api/replacement-requests/{token}/upload**
**Body**
- `size` (integer): Exact file size in bytes, up to 2 GB.
- `contentType` (string, default application/octet-stream): The file's MIME type.
```json title="Response" status="200 OK"
{
"uploadUrl": "",
"tempKey": "uploads/temp/replacement-requests/4b9e1d73-.../9f0c..."
}
```
`PUT` the bytes to `uploadUrl` with `Content-Type: application/octet-stream`. Calling this route again replaces the previous candidate, so a recipient can re-upload until you decide, even after submitting.
##### Recipient: submit the upload
**POST /api/replacement-requests/{token}/submit**
**Body**
- `tempKey` (string): The `tempKey` from the upload step.
- `filename` (string): Original file name, up to 300 characters.
- `contentType` (string, default application/octet-stream): The file's MIME type.
Checks that the bytes arrived and match the declared size, then moves the request to `submitted`. Returns `{ "submitted": true, "filename": "hero-final.webp", "byteSize": 482113 }`. Errors: `400` if the key belongs to another request, the object is missing, or the size differs.
#### Scheduled revisions
Schedule a revision that already exists to become current at a set time, for example a sale banner that should go live at midnight and revert a week later. The revision must be retained; see [Revisions](https://steadylink.io/docs/reference/api/revisions).
##### Schedule a revision
**POST /api/assets/{asset_id}/scheduled-revisions** (scope: `assets:write`)
**Body**
- `version` (integer): Revision number to publish.
- `publishAt` (datetime): When to make it current. At least 10 seconds in the future and within one year.
- `rollbackAt` (datetime): When to switch back to the revision that is current now. Must be after `publishAt`.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/assets/3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44/scheduled-revisions \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "version": 12, "publishAt": "2026-11-27T05:00:00Z", "rollbackAt": "2026-12-04T05:00:00Z" }'
```
```json title="Response" status="200 OK"
{ "id": "a07d5e18-3b4c-4f29-9e61-0d8c2b7f4a35", "status": "queued", "publishAt": "2026-11-27T05:00:00" }
```
The rollback target is fixed when you schedule: it is the revision that was current at that moment, even if other changes happen in between. Returns `404` if the revision does not exist.
##### List scheduled revisions
**GET /api/assets/{asset_id}/scheduled-revisions** (scope: `assets:read`)
Returns jobs that are `queued` or `processing`, soonest first. Each item has `id`, `version`, `publishAt`, `rollbackAt`, and `status`.
##### Cancel a scheduled revision
**DELETE /api/assets/{asset_id}/scheduled-revisions/{job_id}** (scope: `assets:write`)
Cancels a job while it is still `queued` and returns `{ "cancelled": true }`. Once it has started processing, the request fails with `409`.
#### Request forms
A request form asks one or more people for a set of files, for example "logo in SVG, hero image, and signed release PDF". Each requested file has its own type and size rules and its own destination bucket. Respondents open `https://steadylink.io/request/{token}`. Each submission is reviewed file by file.
Active forms per plan: 2 on Hobby, 10 on Personal, 50 on Pro, 250 on Business. A form stops counting once it expires or is revoked. Creating one more returns `409` with `"code": "feature_limit_reached"`.
##### Create a request form
**POST /api/request-forms** (scope: `assets:write`)
**Body**
- `name` (string): Form title shown to respondents, up to 200 characters.
- `instructions` (string): Shown above the file list, up to 5000 characters.
- `expiresAt` (datetime): When the link stops accepting files. Must be in the future and within one year.
- `files` (object[]): From 1 to 25 requested files. Fields below.
**Requested file**
- `label` (string): For example `Hero image`. Up to 200 characters.
- `description` (string): Extra guidance, up to 2000 characters.
- `required` (boolean, default true): The submission cannot be completed without it.
- `acceptedTypes` (string[]): Up to 20 rules. Each is a MIME type (`application/pdf`), a wildcard (`image/*`), or an extension (`.svg`). Empty accepts any type.
- `maxBytes` (integer): Per-file limit. Cannot exceed the plan's upload limit (`413` otherwise).
- `bucketId` (uuid): Where an approved file goes.
- `replacementAssetId` (uuid): Approve this file as a new revision of an existing file instead of creating a new one.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/request-forms \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Launch package",
"instructions": "Use final approved exports.",
"expiresAt": "2026-10-31T23:59:59Z",
"files": [
{ "label": "Hero image", "required": true, "acceptedTypes": ["image/*"], "maxBytes": 26214400, "bucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10" },
{ "label": "Signed release", "required": false, "acceptedTypes": [".pdf"], "maxBytes": 10485760, "bucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10" }
]
}'
```
```json title="Response" status="201 Created"
{
"id": "5d2f8a61-0e7b-4c93-a4d8-1b6e3f9c0a27",
"token": "mR4u...",
"publicPath": "/request/mR4u..."
}
```
Every `bucketId` and `replacementAssetId` must belong to the workspace (`422` otherwise).
##### List request forms
**GET /api/request-forms** (scope: `assets:read`)
Returns every form, newest first, with its requested `files` (each with an `id`), `submissionCount`, `revokedAt`, and the current `publicPath`.
##### Update a request form
**PUT /api/request-forms/{form_id}** (scope: `assets:write`)
Replaces the form's name, instructions, expiration, and requested files. The body has the same fields as create; include each existing file's `id` to keep it, omit a file to remove it, and add files without an `id`. Returns the updated form.
Once anyone has started a submission, the requested files are locked: changing them returns `409`, but you can still edit the name, instructions, and expiration. Expired or revoked forms cannot be edited (`409`).
##### Rotate a request form link
**POST /api/request-forms/{form_id}/rotate** (scope: `assets:write`)
Issues a new token and returns `{ "publicPath": "/request/..." }`. The old link returns `404` immediately; submissions already made are kept.
##### Revoke a request form
**DELETE /api/request-forms/{form_id}** (scope: `assets:write`)
Closes the form. Its public link then returns `410`. Returns `{ "revoked": true }`. Existing submissions stay available for review.
##### List submissions
**GET /api/request-forms/submissions** (scope: `assets:read`)
Returns the 200 most recent submissions, newest first.
```json title="Response" status="200 OK"
{
"items": [
{
"id": "c3e9a0f2-71b4-4d58-8e2a-6f1d0b7c9e43",
"formId": "5d2f8a61-0e7b-4c93-a4d8-1b6e3f9c0a27",
"formName": "Launch package",
"respondentName": "Jo Park",
"respondentEmail": "jo@agency.example",
"status": "submitted",
"submittedAt": "2026-10-09T16:02:11.000000",
"files": [
{
"id": "8a1f6c3d-2e9b-4075-b4c1-9d0e7f2a5b68",
"fieldId": "f2b7d0e4-6c1a-4389-a5f3-0e8d2c9b1a76",
"label": "Hero image",
"filename": "hero-final.webp",
"mime": "image/webp",
"byteSize": 482113,
"status": "submitted",
"assetId": null
}
]
}
]
}
```
A submission is `draft` until the respondent finishes it. Each file is `uploading`, `submitted` (awaiting your decision), `approved`, or `rejected`.
##### Preview or download a submitted file
**GET /api/request-forms/files/{file_id}/preview** (scope: `assets:read`)
**GET /api/request-forms/files/{file_id}/download** (scope: `assets:read`)
Both return short-lived URLs (5 minutes) to the temporary upload so a reviewer can inspect it before deciding. Download returns `{ "url": "..." }`. Preview returns `url`, `mime`, `filename`, `previewable`, and `expiresIn`; it renders inline only for images, audio, video, PDF, JSON, CSV, and plain text. SVG and HTML are never previewed inline, and `previewable` is `false` for them. Both return `404` once the file has been approved or rejected.
##### Decide on a submitted file
**POST /api/request-forms/files/{file_id}/decision** (scope: `assets:write`)
**Body**
- `action` (string): `approve`, `reject`, or `assign`.
- `bucketId` (uuid): For `assign` and `approve`: the destination bucket. Defaults to the requested file's bucket.
- `replacementAssetId` (uuid): For `approve`: publish as a new revision of this file. It must be in the destination bucket.
- `note` (string): Decision note for your records, up to 2000 characters.
- `approve` without a replacement target creates a new file in the `requests/` folder of the destination bucket, renaming it if the name is taken. Returns `{ "status": "approved", "assetId": "..." }`.
- `approve` with a replacement target (from the body or the requested file) adds a new revision to that file.
- `reject` deletes the temporary file and returns `{ "status": "rejected", "assetId": null }`.
- `assign` only changes the destination for a later approval and returns `{ "assigned": true }`.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/request-forms/files/8a1f6c3d-2e9b-4075-b4c1-9d0e7f2a5b68/decision \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "approve", "note": "Approved for campaign use" }'
```
`approve` and `reject` return `409` if the file is not awaiting a decision.
##### Public request form flow
The respondent's browser, or your own upload page, calls these routes with the form token. No credentials are involved. Limits are per IP address and return `429` when exceeded.
| Step | Route | Limit |
| --- | --- | --- |
| Read the form | `GET /api/public/request-forms/{token}` | 120 per minute |
| Start a submission | `POST /api/public/request-forms/{token}/submissions` | 20 per hour |
| Get an upload URL for one file | `POST /api/public/request-forms/{token}/submissions/{submission_id}/uploads` | 60 per hour |
| Confirm one file | `POST /api/public/request-forms/{token}/submissions/{submission_id}/uploads/complete` | 60 per hour |
| Finish the submission | `POST /api/public/request-forms/{token}/submissions/{submission_id}/complete` | 30 per hour |
Every step returns `404` for an unknown token and `410` once the form has expired or been revoked.
**Read the form**
`GET /api/public/request-forms/{token}` returns `id`, `name`, `instructions`, `expiresAt`, and `files`. Use each file's `id` as `fieldId` below, and its `acceptedTypes` and `maxBytes` to validate before uploading.
**Start a submission**
`POST .../submissions` with optional `{ "name": "Jo Park", "email": "jo@agency.example" }`. Returns `201 Created` with `{ "id": "" }`.
**Upload each file**
`POST .../submissions/{submission_id}/uploads` with `{ "fieldId": "...", "filename": "hero-final.webp", "mime": "image/webp", "size": 482113 }`. It returns `{ "fileId": "...", "uploadUrl": "..." }`. `PUT` the bytes to `uploadUrl` within 15 minutes, with `Content-Type: application/octet-stream`.
Errors: `413` above the smaller of the field's `maxBytes` and the plan's upload limit, `415` for a type that matches none of `acceptedTypes`, `409` if this field was already confirmed in this submission. Calling it again for a field that was not confirmed discards the earlier attempt.
**Confirm each file**
`POST .../uploads/complete` with `{ "fileId": "..." }`. SteadyLink checks that the stored size matches `size` (`422` otherwise) and returns `{ "complete": true }`. Safe to repeat.
**Finish**
`POST .../submissions/{submission_id}/complete` returns `{ "complete": true }`, or `422` if a required file is still missing. The workspace is notified with a `request.submitted` event.
#### Website migration
Scan a public website for the media it uses, import the files you choose into a bucket, and download a map from each old URL to its new stable link. The scanner only fetches public `http` and `https` addresses on ports 80 and 443, and it follows redirects only to addresses that pass the same check.
Scan statuses: `queued`, `running`, `complete`, `failed`, `cancelled`, and `importing`.
##### Start a scan
**POST /api/migration-scans** (scope: `assets:write`)
**Body**
- `url` (string): Site to scan. `https://` is added when no scheme is given.
- `maxPages` (integer, default 50): Pages to crawl, from 1 to 200.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/migration-scans \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://www.northwind.example", "maxPages": 50 }'
```
```json title="Response" status="202 Accepted"
{
"id": "0f6b3d82-9c1e-4a57-b2d4-8e7a1c5f3b90",
"url": "https://www.northwind.example",
"status": "queued",
"maxPages": 50,
"pagesScanned": 0,
"mediaFound": 0,
"progress": 0,
"error": null,
"createdAt": "2026-10-08T11:00:00.000000",
"updatedAt": "2026-10-08T11:00:00.000000"
}
```
Limits: 20 scans per workspace per rolling 24 hours (`429`), and a monthly page allowance of 100 on Hobby, 1,000 on Personal, 10,000 on Pro, and 100,000 on Business. A queued or running scan reserves its full `maxPages`; a finished scan counts the pages it actually scanned. Exceeding the allowance returns `409` with `"code": "feature_limit_reached"`. A private or unreachable address returns `422`.
##### List scans
**GET /api/migration-scans** (scope: `assets:read`)
Returns the 100 most recent scans, newest first.
##### Get a scan and its media
**GET /api/migration-scans/{scan_id}** (scope: `assets:read`)
Returns the scan plus `media`, sorted by how often each file is referenced.
```json title="Response" status="200 OK"
{
"id": "0f6b3d82-9c1e-4a57-b2d4-8e7a1c5f3b90",
"status": "complete",
"pagesScanned": 48,
"mediaFound": 212,
"...": "other scan fields",
"media": [
{
"id": "b5e2c8f1-4d7a-4e90-8b3c-1a6f0d9e2c47",
"originalUrl": "https://www.northwind.example/wp-content/uploads/hero.jpg",
"mediaType": "image",
"contentType": "image/jpeg",
"byteSize": 734002,
"statusCode": 200,
"broken": false,
"pageUrls": ["https://www.northwind.example/", "https://www.northwind.example/about"],
"references": 2,
"importedAssetId": null,
"newUrl": null
}
]
}
```
`broken` media could not be fetched and cannot be imported. After import, `importedAssetId` and `newUrl` (the stable link) are filled in.
##### Cancel a scan
**POST /api/migration-scans/{scan_id}/cancel** (scope: `assets:write`)
Stops a `queued`, `running`, or `importing` scan. Returns `{ "cancelled": true }`, or `409` once it has finished.
##### Retry a failed scan
**POST /api/migration-scans/{scan_id}/retry** (scope: `assets:write`)
Queues a `failed` scan again and returns it with status `202 Accepted`. A scan whose import failed is not retried this way; start the import again instead.
##### Import media
**POST /api/migration-scans/{scan_id}/imports** (scope: `assets:write`)
**Body**
- `mediaIds` (uuid[]): From 1 to 200 media IDs from this scan. Each must be not broken and not yet imported.
- `bucketId` (uuid): Destination bucket.
- `path` (string, default website-import): Destination folder. Nested paths like `website-import/images` are allowed; empty, `.`, and `..` segments are not.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/migration-scans/0f6b3d82-9c1e-4a57-b2d4-8e7a1c5f3b90/imports \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "mediaIds": ["b5e2c8f1-4d7a-4e90-8b3c-1a6f0d9e2c47"], "bucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10", "path": "website-import/images" }'
```
```json title="Response" status="202 Accepted"
{ "queued": true }
```
The scan moves to `importing` and back to `complete` when done; poll [the scan](#get-a-scan-and-its-media) for progress. The scan must be `complete`, or `failed` during an earlier import, which lets you resume. Anything else returns `422`. To import more than 200 files, send several requests one after another.
##### Export the URL map
**GET /api/migration-scans/{scan_id}/export/{format}** (scope: `assets:read`)
`format` is `csv` or `json`. Both list only imported media, as `originalUrl` and `steadyLinkUrl` pairs. CSV downloads as `steadylink-mapping-{scan_id}.csv`. JSON adds replacement instructions and snippet examples:
```json title="Response" status="200 OK"
{
"scanId": "0f6b3d82-9c1e-4a57-b2d4-8e7a1c5f3b90",
"mapping": [
{
"originalUrl": "https://www.northwind.example/wp-content/uploads/hero.jpg",
"steadyLinkUrl": "https://cdn.steadylink.io/a/7c3e9f10-2b5d-4a86-9e1f-0d4c8b2a6e53"
}
],
"instructions": "Replace URLs in source control, review the diff, test, and deploy through your normal release process.",
"examples": { "html": "...", "nextjs": "...", "css": "..." }
}
```
#### Personal notification preferences
Notifications belong to a person, not a workspace integration, so these routes require a signed-in session. API keys are refused: reads return `403`, and saving a preference returns `422`.
Events: `asset.replaced`, `asset.rolled_back`, `bandwidth.unusual`, `job.failed`, `link.expiring`, `proofing.submitted`, `request.submitted`, `schedule.published`, `traffic.unusual`. Channels: `in_app`, `email`, `webhook`.
##### Get preferences
**GET /api/notifications/preferences**
Returns one effective preference per event. Your own override wins; otherwise the workspace default applies (`"inherited": true`); with neither, the event is enabled, in-app, and immediate.
```json title="Response" status="200 OK"
{
"events": ["asset.replaced", "asset.rolled_back", "bandwidth.unusual", "job.failed", "link.expiring", "proofing.submitted", "request.submitted", "schedule.published", "traffic.unusual"],
"channels": ["email", "in_app", "webhook"],
"items": [
{ "eventType": "request.submitted", "channels": ["email", "in_app"], "mode": "immediate", "enabled": true, "inherited": false },
{ "eventType": "job.failed", "channels": ["in_app"], "mode": "immediate", "enabled": true, "inherited": false }
]
}
```
##### Save a preference
**PUT /api/notifications/preferences**
Saves your override for one event in the selected workspace.
**Body**
- `eventType` (string): One of the events above.
- `channels` (string[]): At least one of `in_app`, `email`, `webhook`.
- `mode` (string, default immediate): `immediate` or `digest`.
- `enabled` (boolean, default true): Set `false` to stop this event for you.
```bash title="curl"
curl -X PUT https://api.steadylink.io/api/notifications/preferences \
-H "Authorization: Bearer $STEADYLINK_ACCESS_TOKEN" \
-H "X-Workspace-Id: 6e0b2f7a-1c94-4d3b-a8e5-2f71c0d9b6a3" \
-H "Content-Type: application/json" \
-d '{ "eventType": "request.submitted", "channels": ["in_app", "email"], "mode": "immediate", "enabled": true }'
```
```json title="Response" status="200 OK"
{ "eventType": "request.submitted", "channels": ["email", "in_app"], "mode": "immediate", "enabled": true }
```
An unknown event, channel, or mode returns `422`.
##### Read notifications
**GET /api/notifications**
Returns your 100 most recent notifications in the workspace and an `unread` count. Mark one read with `POST /api/notifications/{notification_id}/read`. `GET /api/notifications/deliveries` lists recent email and webhook delivery attempts for your notifications, with `status`, `attempts`, and `lastError`.
#### Next steps
- [Replacement requests guide](https://steadylink.io/docs/guides/replacement-requests): Send a replacement link and review the upload in the dashboard.
- [Asset requests guide](https://steadylink.io/docs/guides/asset-requests): Design a request form that people can complete without help.
- [Website migration guide](https://steadylink.io/docs/guides/website-migration): Scan, import, and replace old URLs in your code.
- [Webhooks](https://steadylink.io/docs/reference/webhooks): Receive workspace events in your own systems.
### REST API: Widgets and collections API
Source: https://steadylink.io/docs/reference/api/sharing
This page covers two ways to share with people outside your workspace. **Upload widgets** let visitors to your own website send you files, restricted to the origins you list. **Collections** gather files into an ordered set and publish it as a portal at `https://steadylink.io/c/{slug}`. Both follow the stable-link model: a widget can replace a file in place, and a collection item can follow the current revision or stay pinned to one.
#### Access model
- **Management routes** accept a signed-in session or an API key. With a key, `GET` routes need `assets:read` and every other method needs `assets:write`. That includes [portal analytics](#get-portal-analytics), which needs `assets:read`, not `analytics:read`.
- **Public routes** under `/api/public/` need no credentials. Widgets are protected by the browser `Origin` and a one-time session token; portals by their access mode.
- **Plan limits.** Active widgets: 1 on Hobby, 3 on Personal, 15 on Pro, 100 on Business. Collections: 3, 15, 100, and 500. Going over returns `409` with `"code": "feature_limit_reached"`.
#### Upload widgets
A widget is a configuration plus a public key (`wdg_...`) that you embed in your site. In `create` mode each upload becomes a new file in a bucket. In `replace` mode each upload becomes a new revision of one file, so its stable link serves the new bytes. With `"approvalMode": "review"`, nothing is published until someone approves it.
##### Create a widget
**POST /api/widgets** (scope: `assets:write`)
**Body**
- `name` (string): Internal name, up to 200 characters.
- `allowedOrigins` (string[]): From 1 to 30 origins allowed to use the widget, as scheme and host only: `https://www.northwind.example`. A value can also hold several origins separated by commas or spaces. Paths, queries, and credentials are rejected.
- `mode` (string, default create): `create` or `replace`.
- `destinationBucketId` (uuid): Required in `create` mode. Bucket for new files.
- `assetId` (uuid): Required in `replace` mode. The file that uploads replace.
- `collectionId` (uuid): Add each published file to this collection.
- `acceptedTypes` (string[]): Up to 30 rules: MIME types (`application/pdf`), wildcards (`image/*`), or extensions (`.pdf`). Empty accepts any type.
- `maxBytes` (integer, default 26214400): Per-file limit, up to 2 GB and no more than the plan's upload limit (`413` otherwise).
- `buttonText` (string, default Choose a file): Up to 80 characters.
- `theme` (string, default light): `light`, `dark`, or `auto`.
- `approvalMode` (string, default review): `review` holds uploads for approval. `auto` publishes them immediately.
- `allowAutoPublish` (boolean, default false): Must be `true` when `approvalMode` is `auto`. It is a deliberate acknowledgment that anyone on an allowed origin can publish.
- `active` (boolean, default true): Inactive widgets refuse uploads and do not count toward the plan limit.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/widgets \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Campaign intake",
"mode": "create",
"destinationBucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10",
"acceptedTypes": ["image/*", ".pdf"],
"maxBytes": 26214400,
"allowedOrigins": ["https://www.northwind.example", "https://staging.northwind.example"],
"approvalMode": "review"
}'
```
```json title="Response" status="201 Created"
{
"id": "2e7a9c40-5b1d-4f83-a6e2-9c0d8b3f1e57",
"name": "Campaign intake",
"publicKey": "wdg_8Hk2...",
"mode": "create",
"destinationBucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10",
"assetId": null,
"collectionId": null,
"acceptedTypes": ["image/*", ".pdf"],
"maxBytes": 26214400,
"buttonText": "Choose a file",
"theme": "light",
"allowedOrigins": ["https://www.northwind.example", "https://staging.northwind.example"],
"approvalMode": "review",
"active": true,
"createdAt": "2026-10-08T12:00:00.000000",
"updatedAt": "2026-10-08T12:00:00.000000"
}
```
Validation errors, such as `auto` without `allowAutoPublish` or `create` without a bucket, return `422`. To embed the widget, see the [upload widget guide](https://steadylink.io/docs/guides/upload-widget).
##### List widgets
**GET /api/widgets** (scope: `assets:read`)
Returns every widget in the workspace, newest first, including `publicKey`. The public key is not a secret; the origin list is what restricts it.
##### Update a widget
**PATCH /api/widgets/{widget_id}** (scope: `assets:write`)
Replaces the whole configuration. Despite the method, send every field as you would for create; omitted fields go back to their defaults. The public key does not change. Reactivating a widget counts toward the plan limit.
##### Delete a widget
**DELETE /api/widgets/{widget_id}** (scope: `assets:write`)
Deletes the widget and any uploads still waiting for approval. Returns `204 No Content`. Pages that embed it stop working.
##### List widget submissions
**GET /api/widget-submissions** (scope: `assets:read`)
Returns the 200 most recent uploads that reached a decision point, newest first.
```json title="Response" status="200 OK"
{
"items": [
{
"id": "d6c1f093-8e2a-4b75-9f40-3a7e5b2d1c86",
"widgetId": "2e7a9c40-5b1d-4f83-a6e2-9c0d8b3f1e57",
"widgetName": "Campaign intake",
"mode": "create",
"filename": "storefront.jpg",
"contentType": "image/jpeg",
"byteSize": 1820443,
"origin": "https://www.northwind.example",
"status": "awaiting_approval",
"resultAssetId": null,
"createdAt": "2026-10-08T12:41:09.000000",
"expiresAt": "2026-10-15T12:41:20.000000"
}
]
}
```
`status` is `awaiting_approval`, `approved`, `rejected`, `failed`, or `expired`. A submission waits seven days for a decision, then can no longer be approved.
##### Download a widget submission
**GET /api/widget-submissions/{submission_id}/download** (scope: `assets:read`)
Returns `url` (download) and `previewUrl` (inline), both valid for 5 minutes, plus `filename` and `contentType`. Only works while the submission is `awaiting_approval`; otherwise `404`.
##### Approve a widget submission
**POST /api/widget-submissions/{submission_id}/approve** (scope: `assets:write`)
In `create` mode, commits the upload as a new file in the `widget-submissions/` folder of the destination bucket. In `replace` mode, adds it as a new revision of the target file. If the widget has a `collectionId`, the file is appended to that collection.
```json title="Response" status="200 OK"
{ "status": "approved", "assetId": "7c3e9f10-2b5d-4a86-9e1f-0d4c8b2a6e53" }
```
Errors: `409` if it is not awaiting approval or the destination no longer exists, `410` once it has expired, `422` if the file fails validation. A failed approval leaves the submission `failed`.
##### Reject a widget submission
**POST /api/widget-submissions/{submission_id}/reject** (scope: `assets:write`)
Deletes the uploaded bytes and returns `{ "status": "rejected" }`.
##### Public: initialize an upload
**POST /api/public/widgets/{public_key}/initialize**
Called from the browser on an allowed origin. SteadyLink checks the `Origin` header, the file's type and size, and the workspace's temporary storage, then returns a presigned upload URL valid for ten minutes.
**Body**
- `filename` (string): Up to 512 characters.
- `size` (integer): Exact size in bytes.
- `contentType` (string, default application/octet-stream): The file's MIME type, checked against `acceptedTypes`.
```json title="Response" status="200 OK"
{
"sessionToken": "Wc0q...",
"uploadUrl": "",
"expiresAt": "2026-10-08T12:51:09.000000",
"permission": "create"
}
```
`PUT` the bytes to `uploadUrl` with `Content-Type: application/octet-stream`. Errors: `403` for an inactive widget or an origin not on the list, `413` above the smaller of the widget's `maxBytes` and the plan's upload limit, `415` for a type that is not accepted.
##### Public: complete an upload
**POST /api/public/widgets/{public_key}/complete**
Send the `sessionToken` as `Authorization: Bearer ` from the same origin that initialized the upload. The token works once.
- With `review`, returns `{ "status": "awaiting_approval", "assetId": null, "replacementRequestId": null }` and notifies the workspace.
- With `auto`, publishes immediately and returns `{ "status": "published", "assetId": "...", "replacementRequestId": null }`. New files go to the root of the destination bucket.
Errors: `409` if the session expired or was already used, `403` if the origin differs, `400` if the uploaded size does not match, `422` if the file fails validation.
CORS preflight requests to `/api/public/widgets/` are answered for any origin, but every actual request is checked against `allowedOrigins`, and the response allows only the validated origin.
#### Collections
A collection is an ordered list of files with an access mode. Each item either follows the file's current revision or is pinned to one retained revision. Items whose revision has not passed the malware scan are left out of the portal.
| `accessMode` | Who can open the portal |
| --- | --- |
| `private` | Nobody. Public routes return `404`. Use it for drafts. |
| `public` | Anyone with the link. |
| `password` | Anyone who [enters the password](#public-exchange-a-password-for-an-access-token). |
| `expiring` | Anyone with the link until `expiresAt`. |
Any collection with an `expiresAt` in the past returns `410` from the public routes, whatever its mode.
##### The collection object
```json title="Collection"
{
"id": "c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48",
"name": "Spring press kit",
"description": "Logos, product shots, and the fact sheet.",
"slug": "spring-press-kit-4f9a1c",
"accessMode": "public",
"expiresAt": null,
"noindex": true,
"viewCount": 312,
"downloadCount": 87,
"itemCount": 12,
"createdAt": "2026-09-30T09:00:00.000000",
"updatedAt": "2026-10-08T12:10:00.000000",
"canCustomizeBranding": true,
"branding": {
"name": "Northwind",
"logoUrl": "https://www.northwind.example/logo.svg",
"url": "https://www.northwind.example",
"primaryColor": "#0b0e0c",
"accentColor": "#b8ff5a",
"removeSteadyLinkBranding": false
}
}
```
The portal URL is `https://steadylink.io/c/` followed by `slug`. The JavaScript SDK adds it to every collection as `url`. `itemCount` is `null` in the response to an update.
##### List collections
**GET /api/collections** (scope: `assets:read`)
```bash title="curl"
curl https://api.steadylink.io/api/collections \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const { items } = await steadylink.listCollections();
```
Returns `{ "items": [...] }` with every collection, most recently updated first.
##### Create a collection
**POST /api/collections** (scope: `assets:write`)
**Body**
- `name` (string): Up to 200 characters. Also used to build the slug, with a random suffix.
- `description` (string): Up to 2000 characters.
- `accessMode` (string, default private): `private`, `public`, `password`, or `expiring`.
- `password` (string): 8 to 128 characters. Required for `password`. Stored only as a hash.
- `expiresAt` (datetime): Required and in the future for `expiring`. Optional for other modes.
- `noindex` (boolean, default true): Ask search engines not to index the portal.
- `brandName` (string): Portal branding. Branding fields require Pro, Business, or Enterprise; otherwise the request fails with `403`.
- `brandLogoUrl` (string): HTTPS URL of a logo.
- `brandUrl` (string): HTTPS link for the brand name.
- `primaryColor` (string): Six-digit hex, such as `#0b0e0c`.
- `accentColor` (string): Six-digit hex.
- `removeSteadyLinkBranding` (boolean, default false): Hide SteadyLink branding on the portal.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/collections \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Spring press kit", "accessMode": "password", "password": "tulip-harbor-91" }'
```
```ts title="JavaScript"
const collection = await steadylink.createCollection({
name: "Spring press kit",
accessMode: "password",
password: "tulip-harbor-91",
});
console.log(collection.url);
```
Returns the [collection object](#the-collection-object) with status `201 Created`.
##### Get a collection
**GET /api/collections/{collection_id}** (scope: `assets:read`)
```bash title="curl"
curl https://api.steadylink.io/api/collections/c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48 \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const collection = await steadylink.getCollection("c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48");
```
Returns the collection object plus its `items` in portal order:
```json title="Response" status="200 OK"
{
"id": "c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48",
"name": "Spring press kit",
"...": "other collection fields",
"items": [
{
"id": "61f0b8d3-a2c7-4e95-8b14-7d3e0a9c2f56",
"assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44",
"name": "logo-primary.svg",
"position": 0,
"version": 3,
"pinned": false,
"mime": "image/svg+xml",
"byteSize": 18244,
"width": null,
"height": null,
"createdAt": "2026-09-30T09:02:00.000000",
"previewUrl": "/api/public/collections/spring-press-kit-4f9a1c/items/61f0b8d3-a2c7-4e95-8b14-7d3e0a9c2f56/preview"
}
]
}
```
`version` is the revision the portal serves now: the pinned one, or the current one when `pinned` is `false`.
##### Update a collection
**PATCH /api/collections/{collection_id}** (scope: `assets:write`)
Send only the fields to change; they are the same as for create. Switching to `password` requires a password unless one is already set, and switching to `expiring` requires an `expiresAt` unless one is already set (`422` otherwise). Send `"expiresAt": null` to remove an expiration.
```bash title="curl"
curl -X PATCH https://api.steadylink.io/api/collections/c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48 \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "accessMode": "public" }'
```
```ts title="JavaScript"
await steadylink.updateCollection("c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48", { accessMode: "public" });
```
##### Delete a collection
**DELETE /api/collections/{collection_id}** (scope: `assets:write`)
Deletes the collection and its items, not the files. Returns `204 No Content`. The portal link stops working.
##### Add an item
**POST /api/collections/{collection_id}/items** (scope: `assets:write`)
**Body**
- `assetId` (uuid): A file in the workspace.
- `version` (integer): Pin this revision. Omit to follow the current revision, so replacements show up in the portal automatically.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/collections/c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48/items \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44", "version": 12 }'
```
```ts title="JavaScript"
await steadylink.addToCollection("c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48", "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44", 12);
```
```json title="Response" status="201 Created"
{ "id": "61f0b8d3-a2c7-4e95-8b14-7d3e0a9c2f56", "assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44", "position": 4, "pinnedVersion": 12 }
```
New items go to the end. Errors: `409` if the file is already in the collection, `422` if the file or revision does not exist.
##### Reorder items
**PUT /api/collections/{collection_id}/items/order** (scope: `assets:write`)
Send `itemIds` with every item ID in the collection exactly once, in the new order. A missing, extra, or repeated ID returns `422`, which protects you from overwriting a change someone else just made. Returns `{ "updated": true }`.
```bash title="curl"
curl -X PUT https://api.steadylink.io/api/collections/c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48/items/order \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "itemIds": ["61f0b8d3-a2c7-4e95-8b14-7d3e0a9c2f56", "0b9d4e7a-5c21-4f38-a6e0-2d8b1f7c3e94"] }'
```
```ts title="JavaScript"
await steadylink.reorderCollection(collectionId, [firstItemId, secondItemId]);
```
##### Pin or unpin an item
**PATCH /api/collections/{collection_id}/items/{item_id}** (scope: `assets:write`)
Send `{ "version": 12 }` to pin a revision, or `{ "version": null }` to follow the current revision again. Returns `{ "updated": true, "pinnedVersion": 12 }`, or `422` if the revision does not exist.
```bash title="curl"
curl -X PATCH https://api.steadylink.io/api/collections/c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48/items/61f0b8d3-a2c7-4e95-8b14-7d3e0a9c2f56 \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "version": null }'
```
```ts title="JavaScript"
await steadylink.pinCollectionItem(collectionId, itemId, null);
```
##### Remove an item
**DELETE /api/collections/{collection_id}/items/{item_id}** (scope: `assets:write`)
Removes the item from the collection, not the file. Returns `204 No Content`. In JavaScript: `steadylink.removeFromCollection(collectionId, itemId)`.
##### Get portal analytics
**GET /api/collections/{collection_id}/analytics** (scope: `assets:read`)
Returns total views and downloads plus up to the last 90 days that had activity, oldest first.
```json title="Response" status="200 OK"
{
"viewCount": 312,
"downloadCount": 87,
"days": [
{ "day": "2026-10-07", "views": 41, "downloads": 9 },
{ "day": "2026-10-08", "views": 18, "downloads": 3 }
]
}
```
A view is counted each time the portal data is loaded; a download each time an item is downloaded. Previews are not counted as downloads.
##### Public: exchange a password for an access token
**POST `/api/public/collections/{slug**
Send `{ "password": "tulip-harbor-91" }`. Returns `{ "accessToken": "...", "expiresIn": 3600 }`, a token valid for one hour or until the collection expires, whichever is sooner. Pass it as the `access` query parameter on the other public routes. A wrong password returns `401`.
##### Public: read a portal
**GET /api/public/collections/{slug}**
**Query parameters**
- `access` (string): The token from the password exchange. Required for `password` collections.
- `count` (boolean, default true): Set `false` to load the portal without counting a view, for example when re-rendering.
Returns the collection fields, the resolved `branding`, and `items` as in [Get a collection](#get-a-collection). The resolved branding includes `showSteadyLinkBranding`, and falls back to the workspace name and default colors when no custom branding applies. Errors: `404` for an unknown or private collection, `410` once expired, `401` with `"code": "collection_password_required"` when a password collection is opened without a valid token.
##### Public: preview an item
**GET /api/public/collections/{slug}/items/{item_id}/preview**
Redirects (`307`) to a short-lived URL for inline display, or streams the bytes for encrypted files. Previews count toward the workspace's delivery usage but not toward portal downloads.
##### Public: download an item
**GET /api/public/collections/{slug}/items/{item_id}/download**
Redirects (`307`) to a short-lived URL that downloads the file under its name, or streams it for encrypted files. Counts a portal download and delivery usage. Returns `409` if the revision has not passed scanning.
#### Client proofing
Proofing lets people opening a portal pick favorite items and send them to the workspace. The settings live on the collection and come back on every collection object as `proofing`:
```json title="proofing"
{
"enabled": true,
"selectionLimit": 25,
"downloadsEnabled": false,
"watermark": { "enabled": true, "kind": "text", "text": "© Northwind Studio", "logoAssetId": null, "position": "br", "tile": false, "opacity": 50, "size": 25 }
}
```
Change them with [Update a collection](#update-a-collection) using these fields:
| Field | Rules |
| --- | --- |
| `proofingEnabled` | `true` lets visitors pick items. |
| `selectionLimit` | 0 to 10,000. `0` removes the limit. |
| `downloadsEnabled` | `false` makes the public download route return `403` with `"code": "downloads_off"`. |
| `watermark` | `kind` is `text` or `logo`. `text` is up to 60 characters. `logoAssetId` must be a file in the same workspace (`422` otherwise). `position` is one of `tl`, `tc`, `tr`, `cl`, `c`, `cr`, `bl`, `bc`, `br`. `opacity` is 5 to 100 and `size` is 5 to 80, a percentage of the image width. Out-of-range values are clamped. |
With an active watermark, public previews and downloads of JPEG, PNG, WebP, HEIC, HEIF, AVIF, and TIFF items are rendered with the mark, and portal items leave out `assetId`. The public portal response reports only `"watermark": { "enabled": true }`.
##### List selections
**GET /api/collections/{collection_id}/selections** (scope: `assets:read`)
Returns `{ "items": [...] }`, sent selections first. Each selection has `id`, `name`, `email`, `note`, `itemIds` (collection item IDs in the order they were picked), `submittedAt` (`null` while the visitor is still picking), and `updatedAt`.
##### Reopen a selection
**POST /api/collections/{collection_id}/selections/{selection_id}/reopen** (scope: `assets:write`)
Clears `submittedAt` so the visitor can change their picks and send again. Returns the selection.
##### Delete a selection
**DELETE /api/collections/{collection_id}/selections/{selection_id}** (scope: `assets:write`)
Returns `204 No Content`.
##### Public: start or resume a selection
**POST /api/public/collections/{slug}/selection**
Send `{ "name": "Ana Hendricks", "email": "ana@example.com" }`. Returns `{ "token": "sls_...", "selection": {...} }`. Send the token as the `X-Selection-Token` header on the routes below. The same email resumes its existing selection and replaces its previous token. Returns `404` with `"code": "proofing_off"` when proofing is off. Like the other public routes, it takes the `access` query parameter for password collections.
##### Public: read, pick, and send
| Route | What it does |
| --- | --- |
| `GET /api/public/collections/{slug}/selection` | The visitor's selection. |
| `PUT /api/public/collections/{slug}/selection/items/{item_id}` | Picks an item. Picking one twice is a no-op. `409` with `selection_limit_reached` at the limit. |
| `DELETE /api/public/collections/{slug}/selection/items/{item_id}` | Removes a pick. |
| `POST /api/public/collections/{slug}/selection/submit` | Sends the picks with an optional `note` (up to 2,000 characters) and sends the `proofing.submitted` notification. `422` with `selection_empty` when nothing is picked. |
A missing or replaced token returns `401` with `selection_token_missing` or `selection_token_invalid`. Changing a sent selection returns `409` with `selection_submitted` until the workspace reopens it.
#### Next steps
- [Upload widget guide](https://steadylink.io/docs/guides/upload-widget): Embed a widget on your site and review what people send.
- [Collections and portals guide](https://steadylink.io/docs/guides/collections-and-portals): Build a press kit or client portal from the dashboard.
- [Revisions](https://steadylink.io/docs/concepts/revisions): How following and pinning revisions works.
### REST API: Platform controls API
Source: https://steadylink.io/docs/reference/api/platform
These routes control how a whole workspace behaves: where files may be delivered, how images crop, how large libraries move in, which hostnames serve files, whether originals are encrypted, and how people sign in. Most of them are workspace administration, so read [Access](#access) before giving an integration a key for them.
#### Access
Every route under `/api/platform/` follows the workspace-administration scopes for API keys: `GET` needs `workspace:read`, and every other method needs `workspace:admin`. This applies even to routes that change a single file, such as the focal point.
| Route group | API key scope | Signed-in role |
| --- | --- | --- |
| `/api/platform/delivery-policy` | `workspace:read` to read, `workspace:admin` to change | Owner or admin |
| `/api/platform/domains` | Same | Owner or admin |
| `/api/platform/sso` | Same | Owner or admin |
| `/api/platform/webhooks` | Same | Owner or admin |
| `/api/platform/assets/{asset_id}/focal-point` | `workspace:admin` | Member or above |
| `/api/platform/migrations` | `workspace:read` to list, `workspace:admin` to create | Any member to list, member or above to create |
| Encryption setting | Not available to API keys | Owner or admin |
**Note.** The CLI's `steadylink focal` command and the SDKs' focal point and migration methods call these routes, so the key they use needs `workspace:admin`. Give that scope to a dedicated key rather than to every uploader.
#### Regional delivery policy
Restrict which countries can receive your files, and read the storage region the workspace uses.
##### Get the delivery policy
**GET /api/platform/delivery-policy** (scope: `workspace:read`)
```bash title="curl"
curl https://api.steadylink.io/api/platform/delivery-policy \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const policy = await steadylink.getDeliveryPolicy();
```
```python title="Python"
policy = client.get_delivery_policy()
```
```json title="Response" status="200 OK"
{
"mode": "all",
"countries": [],
"storageRegion": "auto",
"availableStorageRegions": ["auto"]
}
```
`storageRegion` is where originals are stored. Each deployment currently offers one region, listed in `availableStorageRegions`.
##### Set the delivery policy
**PUT /api/platform/delivery-policy** (scope: `workspace:admin`)
**Body**
- `mode` (string, default all): `all` delivers everywhere. `allow` delivers only to the listed countries. `deny` delivers everywhere except the listed countries.
- `countries` (string[]): Two-letter ISO 3166 codes, case-insensitive, up to 249. At least one is required for `allow` and `deny`. Duplicates are removed and the list is sorted.
- `storageRegion` (string): Optional. Must equal the current region; any other value returns `422` with `"code": "unsupported_storage_region"`.
```bash title="curl"
curl -X PUT https://api.steadylink.io/api/platform/delivery-policy \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "mode": "deny", "countries": ["KP", "RU"] }'
```
```ts title="JavaScript"
await steadylink.setDeliveryPolicy({ mode: "deny", countries: ["KP", "RU"] });
```
```python title="Python"
client.set_delivery_policy({"mode": "deny", "countries": ["KP", "RU"]})
```
```json title="Response" status="200 OK"
{ "mode": "deny", "countries": ["KP", "RU"], "storageRegion": "auto", "availableStorageRegions": ["auto"] }
```
A blocked delivery request receives `451` ("This asset is not available in your region"). The policy fails closed: with `allow` or `deny` active, a delivery request is served only when it arrives through SteadyLink's edge, which attaches the visitor's country. A request that reaches delivery any other way is refused with `451`, even from a permitted country. Policy changes can take a short time to reach every delivery server, because the policy is cached briefly.
#### Focal points
A focal point is the part of an image that must stay visible when a transformation crops it. Set it once per file; every delivery URL that crops with `fit=cover` then keeps that point in frame, including URLs already in use once their cached copies expire.
##### Set a focal point
**PUT /api/platform/assets/{asset_id}/focal-point** (scope: `workspace:admin`)
**Body**
- `x` (number): Horizontal position from 0 (left edge) to 1 (right edge). Rounded to four decimals.
- `y` (number): Vertical position from 0 (top edge) to 1 (bottom edge).
```bash title="curl"
curl -X PUT https://api.steadylink.io/api/platform/assets/3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44/focal-point \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "x": 0.42, "y": 0.31 }'
```
```ts title="JavaScript"
await steadylink.setFocalPoint("3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44", 0.42, 0.31);
```
```python title="Python"
client.set_focal_point("3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44", 0.42, 0.31)
```
```bash title="CLI"
steadylink focal 3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44 --x 0.42 --y 0.31
```
```json title="Response" status="200 OK"
{ "x": 0.42, "y": 0.31 }
```
The stored point applies only when a delivery URL uses `fit=cover`, has no explicit `fp-x` and `fp-y` (or `focal-x` and `focal-y`), and does not set `focus=auto`. Explicit URL parameters always win, so one URL can still override the stored point. Values outside 0 to 1 return `422`. The point belongs to the file, not to a revision, so it stays in place when you replace the file; set it again if the new image is composed differently. See [Image transformations](https://steadylink.io/docs/guides/image-transformations) for the URL parameters.
##### Clear a focal point
**DELETE /api/platform/assets/{asset_id}/focal-point** (scope: `workspace:admin`)
Removes the stored point and returns `204 No Content`. Cover crops go back to centering.
#### Bulk migrations
A migration is an upload batch of up to 100 files that keeps each file's folder path from your manifest, plus a record you can list later. Use it to move a library from another storage service while preserving its folder structure. For importing media from a live website by URL, use [website migration](https://steadylink.io/docs/reference/api/requests#website-migration) instead.
##### Create a migration
**POST /api/platform/migrations** (scope: `workspace:admin`)
**Body**
- `bucketId` (uuid): Destination bucket.
- `files` (object[]): From 1 to 100 files, each with `filename` (required), `size` in bytes (required), `path` (folder inside the bucket, default empty), and `contentType` (default `application/octet-stream`).
```bash title="curl"
curl -X POST https://api.steadylink.io/api/platform/migrations \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"bucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10",
"files": [
{ "filename": "hero.jpg", "path": "2024/spring/", "contentType": "image/jpeg", "size": 734002 },
{ "filename": "catalog.pdf", "path": "2024/print/", "contentType": "application/pdf", "size": 5242880 }
]
}'
```
```ts title="JavaScript"
const migration = await steadylink.createMigration("9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10", [
{ filename: "hero.jpg", path: "2024/spring/", contentType: "image/jpeg", size: 734002 },
{ filename: "catalog.pdf", path: "2024/print/", contentType: "application/pdf", size: 5242880 },
]);
```
```python title="Python"
from steadylink import UploadFile
migration = client.create_migration("9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10", [
UploadFile("hero.jpg", 734002, "image/jpeg", "2024/spring/"),
UploadFile("catalog.pdf", 5242880, "application/pdf", "2024/print/"),
])
```
```json title="Response" status="201 Created"
{
"id": "8f3a1c6e-2d94-4b70-a5e8-0c7b9d2f4e13",
"status": "uploading",
"uploadBatch": {
"id": "1e6d9b42-7c3a-4f85-9e01-5b2c8a7d0f36",
"status": "active",
"totalFiles": 2,
"files": [
{
"id": "5a0c7e93-1b4d-4862-b9f7-3e2a6d8c1f50",
"filename": "hero.jpg",
"path": "2024/spring/",
"expectedSize": 734002,
"status": "created",
"uploadUrl": "",
"...": "other upload session fields"
}
]
}
}
```
Then, for each file, `PUT` its bytes to `uploadUrl` and complete its upload session, exactly as in the [Uploads API](https://steadylink.io/docs/reference/api/uploads). When every file in the batch has finished, the migration becomes `complete`, or `partial` if any file failed or was blocked by the scan, and a `migration.completed` webhook event is sent. Each file is checked against the plan's upload size and storage limits when the migration is created. For more than 100 files, create several migrations.
##### List migrations
**GET /api/platform/migrations** (scope: `workspace:read`)
```bash title="curl"
curl https://api.steadylink.io/api/platform/migrations \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const { items } = await steadylink.listMigrations();
```
```python title="Python"
items = client.list_migrations()["items"]
```
```json title="Response" status="200 OK"
{
"items": [
{
"id": "8f3a1c6e-2d94-4b70-a5e8-0c7b9d2f4e13",
"bucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10",
"uploadBatchId": "1e6d9b42-7c3a-4f85-9e01-5b2c8a7d0f36",
"status": "complete",
"totalFiles": 2,
"createdAt": "2026-10-08T13:00:00.000000"
}
]
}
```
Returns the 50 most recent migrations. A migration still in progress shows `created` here.
#### Delivery domains
Serve files from your own hostname, such as `files.northwind.example`, instead of `cdn.steadylink.io`. Adding a domain is a two-part process: you prove you own it with a DNS TXT record, then SteadyLink completes the delivery setup for that hostname. The API covers ownership; `deliveryReady` stays `false` and `edgeStatus` reads `manual_setup_required` until the setup is finished on SteadyLink's side.
##### The domain object
```json title="Domain"
{
"id": "4d8e2a17-9b3c-4f60-a1d5-7e0c3b9f2a84",
"hostname": "files.northwind.example",
"status": "pending",
"verification": {
"type": "TXT",
"name": "_steadylink.files.northwind.example",
"value": "sl-domain=Qm3x..."
},
"cnameTarget": "cdn.steadylink.io",
"verifiedAt": null,
"edgeStatus": "waiting_for_ownership",
"deliveryReady": false,
"createdAt": "2026-10-08T13:10:00.000000"
}
```
`status` is `pending` until verification succeeds, then `verified`. Point the hostname's CNAME at `cnameTarget`.
##### List delivery domains
**GET /api/platform/domains** (scope: `workspace:read`)
```bash title="curl"
curl https://api.steadylink.io/api/platform/domains \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const { items } = await steadylink.listDeliveryDomains();
```
```python title="Python"
items = client.list_delivery_domains()["items"]
```
Returns `{ "items": [...] }` with every domain, oldest first.
##### Add a delivery domain
**POST /api/platform/domains** (scope: `workspace:admin`)
Send the bare hostname, without a protocol or path.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/platform/domains \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "hostname": "files.northwind.example" }'
```
```ts title="JavaScript"
const domain = await steadylink.addDeliveryDomain("files.northwind.example");
```
```python title="Python"
domain = client.add_delivery_domain("files.northwind.example")
```
Returns the [domain object](#the-domain-object) with status `201 Created`. Create the TXT record from `verification` at your DNS provider. Errors: `422` with `"code": "invalid_delivery_domain"` for a URL, an IP address, or a `steadylink.io` hostname; `409` with `"code": "delivery_domain_claimed"` if any workspace already added that hostname.
##### Verify a delivery domain
**POST /api/platform/domains/{domain_id}/verify** (scope: `workspace:admin`)
Looks up the TXT record and marks the domain `verified` if the value matches.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/platform/domains/4d8e2a17-9b3c-4f60-a1d5-7e0c3b9f2a84/verify \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.verifyDeliveryDomain("4d8e2a17-9b3c-4f60-a1d5-7e0c3b9f2a84");
```
```python title="Python"
client.verify_delivery_domain("4d8e2a17-9b3c-4f60-a1d5-7e0c3b9f2a84")
```
Returns the updated domain. If the record is not visible yet, the response is `409` with `"code": "domain_verification_pending"` and the expected `record`. DNS changes can take minutes to hours to propagate, so retry with a growing delay rather than in a tight loop.
##### Remove a delivery domain
**DELETE /api/platform/domains/{domain_id}** (scope: `workspace:admin`)
Returns `204 No Content`. The hostname becomes available to claim again.
#### Encryption of originals
When encryption is on, SteadyLink encrypts each newly committed original before it is stored. Revisions committed earlier are not re-encrypted, and turning the setting off does not decrypt anything. The workspace object reports the current state as `encryptionEnabled`.
##### Turn encryption on or off
**POST /api/orgs/{workspace_id}/encryption?enabled=true**
The setting is a query parameter, not a JSON body. Send `enabled=false` to turn it off.
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/orgs/6e0b2f7a-1c94-4d3b-a8e5-2f71c0d9b6a3/encryption?enabled=true" \
-H "Authorization: Bearer $STEADYLINK_ACCESS_TOKEN"
```
```json title="Response" status="200 OK"
{ "orgId": "6e0b2f7a-1c94-4d3b-a8e5-2f71c0d9b6a3", "encryptionEnabled": true }
```
Returns `503` when encryption is not configured on the server. Encrypted files cannot be used as a [conversion](https://steadylink.io/docs/reference/api/conversions) source.
#### Single sign-on
Connect an OpenID Connect provider (Okta, Microsoft Entra ID, Google Workspace, or any provider with a discovery document) so people sign in with their company account. SSO requires the Business plan or above; other plans get `403` with `"code": "business_plan_required"`. For the full setup, see [Single sign-on](https://steadylink.io/docs/security/single-sign-on).
##### Get the SSO configuration
**GET /api/platform/sso** (scope: `workspace:read`)
Returns `{ "configured": false, "available": true }` before setup, where `available` says whether the plan includes SSO. Once configured, it also returns `issuer`, `clientId`, `emailDomains`, `enabled`, `enforce`, `testedAt`, `startPath`, and `callbackPath`. The client secret is never returned.
##### Configure SSO
**PUT /api/platform/sso** (scope: `workspace:admin`)
**Body**
- `issuer` (string): The provider's HTTPS issuer URL. SteadyLink reads `/.well-known/openid-configuration` from it at sign-in.
- `clientId` (string): The client ID from your provider.
- `clientSecret` (string): 8 to 4096 characters. Required the first time; omit it later to keep the stored secret. Stored encrypted.
- `emailDomains` (string[]): From 1 to 25 domains, such as `northwind.example`. Only people whose verified email is on one of these domains can sign in.
- `enabled` (boolean, default false): Turn the connection on.
- `enforce` (boolean, default false): Require SSO for every member. Only allowed after a successful test login.
In your provider, register the redirect URI `https://api.steadylink.io/api/platform/sso/callback`.
```bash title="curl"
curl -X PUT https://api.steadylink.io/api/platform/sso \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"issuer": "https://northwind.okta.com",
"clientId": "0oa8x2k4mQ7",
"clientSecret": "'"$OKTA_CLIENT_SECRET"'",
"emailDomains": ["northwind.example"],
"enabled": true,
"enforce": false
}'
```
```ts title="JavaScript"
await steadylink.configureSso({
issuer: "https://northwind.okta.com",
clientId: "0oa8x2k4mQ7",
clientSecret: process.env.OKTA_CLIENT_SECRET,
emailDomains: ["northwind.example"],
enabled: true,
enforce: false,
});
```
```json title="Response" status="200 OK"
{
"configured": true,
"issuer": "https://northwind.okta.com",
"clientId": "0oa8x2k4mQ7",
"emailDomains": ["northwind.example"],
"enabled": true,
"enforce": false,
"testedAt": null,
"startPath": "/api/platform/sso/6e0b2f7a-1c94-4d3b-a8e5-2f71c0d9b6a3/start",
"callbackPath": "/api/platform/sso/callback"
}
```
Enforcement is a three-step sequence, and the API rejects shortcuts:
**Save and enable**
`PUT` the configuration with `"enabled": true` and `"enforce": false`.
**Test the login**
Open `https://api.steadylink.io` followed by `startPath` in a browser and sign in with an account on one of the email domains. A successful sign-in sets `testedAt`.
**Enforce**
`PUT` the same configuration with `"enforce": true`. Without a successful test, this returns `409` with `"code": "sso_test_required"`.
Changing the issuer, client ID, email domains, or secret clears `testedAt`. If SSO is already enforced, that `PUT` fails with `409` until you test again, so a broken configuration cannot lock everyone out. Disabling the connection (`"enabled": false`) also turns enforcement off.
While SSO is enforced, a member who signed in another way gets `403` with `"code": "sso_required"` and an `ssoUrl` on every workspace request. API keys are not affected. People who sign in through SSO for the first time join the workspace as members, if a seat is available. Invalid issuers or domains return `422` with `"code": "invalid_sso_configuration"`.
#### Webhook registration
Webhook endpoints are registered under `/api/platform/webhooks` with the same scopes as the rest of this page. Payloads, event types, signatures, Slack, Discord, and Teams destinations, and retries are documented on [Webhooks](https://steadylink.io/docs/reference/webhooks).
| Method | Route | Purpose |
| --- | --- | --- |
| `GET` | `/api/platform/webhooks` | List endpoints and the available event types |
| `POST` | `/api/platform/webhooks` | Register an endpoint |
| `POST` | `/api/platform/webhooks/{webhook_id}/test` | Send a test event now |
| `GET` | `/api/platform/webhooks/{webhook_id}/deliveries` | Recent delivery attempts |
| `DELETE` | `/api/platform/webhooks/{webhook_id}` | Remove an endpoint |
#### Next steps
- [Single sign-on](https://steadylink.io/docs/security/single-sign-on): Set up OIDC with your identity provider, step by step.
- [Image transformations](https://steadylink.io/docs/guides/image-transformations): Crop and resize on delivery with `fit=cover` and focal points.
- [Webhooks](https://steadylink.io/docs/reference/webhooks): Get notified when migrations complete and revisions publish.
- [Uploads API](https://steadylink.io/docs/reference/api/uploads): Send bytes and complete the sessions a migration creates.
### TypeScript SDK
Source: https://steadylink.io/docs/reference/sdk
`@steadylink/sdk` is the official TypeScript client for the SteadyLink API. Use this page when you are writing server code, a build script, or a Next.js app and need the exact signature, options, and return shape of a method. Every method maps to one REST route (named under each heading), so anything you read here also holds for the [REST API](https://steadylink.io/docs/reference/api).
The package is ESM with bundled type definitions and no runtime dependencies. It runs anywhere standard `fetch` exists: Node.js 18 and later, Bun, Deno, edge runtimes, and browsers.
#### Install
```bash
npm install @steadylink/sdk
```
The package has three entry points:
| Import path | Contents | Runtime |
| --- | --- | --- |
| `@steadylink/sdk` | The `SteadyLink` client, URL helpers, error classes, and types. | Any |
| `@steadylink/sdk/node` | `fileFromPath()` for streaming files from disk. | Node.js only |
| `@steadylink/sdk/next-loader` | A custom loader for `next/image`. | Any |
#### Create a client
```ts title="steadylink.ts"
import { SteadyLink } from "@steadylink/sdk";
export const steadylink = new SteadyLink({
apiKey: process.env.STEADYLINK_API_KEY!,
});
```
Create a key in **Dashboard > Developers**. Give it `assets:read` to read and `assets:write` to upload, replace, or delete. The [platform helpers](#platform-helpers) need `workspace:read` or `workspace:admin`; see [API keys](https://steadylink.io/docs/security/api-keys) for every scope.
The constructor throws a `TypeError` when you pass both `apiKey` and `accessToken`, or neither, and when no `fetch` implementation is available.
**Client options**
- `apiKey` (string): A workspace API key, sent as `X-API-Key`. Server-side only: never ship it in a browser bundle, mobile app, or repository. Use this or `accessToken`, not both.
- `accessToken` (string): A short-lived user access token, sent as `Authorization: Bearer`. Use this or `apiKey`.
- `workspaceId` (string): Sent as `X-Workspace-Id`. API keys already belong to one workspace, so you only need this with an `accessToken` for a user who belongs to several workspaces.
- `baseUrl` (string, default https://api.steadylink.io): API origin. Trailing slashes are removed.
- `cdnUrl` (string, default https://cdn.steadylink.io): Delivery origin used by `link()` and every `url` the SDK returns. Set it to a custom delivery domain to print links on that host.
- `appUrl` (string, default https://steadylink.io): Web app origin used to build collection portal URLs (`{appUrl}/c/{slug}`).
- `timeoutMs` (number, default 30000): Timeout for each JSON API call, in milliseconds. `0` disables it. Byte uploads are not covered by this timeout.
- `uploadTimeoutMs` (number, default 0): Timeout for each byte upload. `0` means no timeout, which is what you want for large files on slow links.
- `maxRetries` (number, default 2): How many times a retryable request is retried. See [Retries](#retries).
- `fetch` (typeof fetch, default globalThis.fetch): A custom Fetch implementation, for tests or proxies. When set, browser upload progress falls back to stream counting instead of `XMLHttpRequest`.
The client exposes `baseUrl`, `cdnUrl`, and `appUrl` as read-only properties after normalization.
#### Uploads
##### upload
Uploads one or more files and returns one result per file, each with its stable link. This is the method to reach for in almost every case.
```ts
upload(bucketId: string, input: UploadSource | UploadSource[], options?: UploadOptions): Promise
```
Under the hood it splits the input into batches of 100 ([`POST /api/upload-batches`](https://steadylink.io/docs/reference/api/uploads)), sends the bytes for up to `concurrency` files in parallel, completes each session with an idempotency key, and then polls until SteadyLink has finalized the files so each result carries its asset ID. A file that fails to transfer is cancelled so it does not hold storage.
**UploadSource**
- `filename` (string): File name, including extension. The key becomes `{folder}{path}{filename}`.
- `data` (UploadData): `Blob`, `File`, `ArrayBuffer`, typed array, `ReadableStream`, or `string`.
- `size` (number): Byte length. Required only when `data` is a `ReadableStream`; otherwise it is measured. A stream without a size throws a `TypeError` before any request is made.
- `contentType` (string): Stored content type. Defaults to the `Blob` type, then a guess from the extension (see [contentTypeFor](#contenttypefor)), then `application/octet-stream`.
- `path` (string): Folder for this file, joined after `options.folder`. `".."` segments throw.
**UploadOptions**
- `folder` (string, default bucket root): Folder prefix for every file, for example `campaign/launch`. Backslashes become slashes and a trailing slash is added.
- `visibility` ("public" | "private"): Set each finished file to this visibility. Omit it to inherit the bucket default. Setting it forces `wait`, because visibility can only be set on a finalized file.
- `wait` (boolean | WaitOptions, default true): Wait for finalization. `false` returns as soon as the bytes are sent, so `assetId` and `url` may be `null`. Pass `{ timeoutMs, intervalMs, signal }` to tune polling (defaults: 300000 ms timeout, 750 ms first interval, growing 1.5 times per poll up to 3 s).
- `concurrency` (number, default 4): Parallel byte uploads.
- `onProgress`: void">Called with `{ filename, index, loaded, total }` as bytes are sent. `index` is the position in the input array.
- `signal` (AbortSignal): Cancels in-flight transfers and polling. An abort rejects the whole call instead of being recorded as a per-file failure.
- `via` ("presigned" | "api", default presigned): `presigned` sends bytes straight to object storage. `api` streams them through `PUT /api/upload-sessions/{id}/content`, for networks that block the storage host.
- `throwOnError` (boolean, default true): Throw `SteadyLinkUploadError` when any file fails. Set `false` to always get the full result array and inspect each `status`.
Each `UploadedFile` has this shape:
```ts
interface UploadedFile {
uploadSessionId: string;
batchId: string;
bucketId: string;
filename: string;
key: string; // "campaign/hero.webp"
status: string; // ready, committing, scanning, blocked, failed, or cancelled
assetId: string | null; // null until finalized, or when the file failed
revisionNumber: number | null;
url: string | null; // https://cdn.steadylink.io/a/{assetId}
visibility?: "public" | "private";
error: { code: string; message: string } | null;
}
```
```ts title="Example"
import { fileFromPath } from "@steadylink/sdk/node";
const results = await steadylink.upload(bucketId, [
await fileFromPath("./hero.webp"),
{ filename: "notes.txt", data: "Launch notes" },
], {
folder: "launch/2026",
visibility: "public",
onProgress: ({ filename, loaded, total }) => console.log(filename, Math.round((loaded / total) * 100)),
});
for (const file of results) console.log(file.key, file.url);
```
Uploading to a key that already exists adds a new revision to that file instead of creating a second one, so the existing link starts serving the new bytes. Use [replace](#replace) when you mean to do that on purpose.
Byte uploads are never retried automatically. If a transfer fails, the result for that file has `status: "failed"` and an `error`, and the others continue.
##### uploadFile
Creates a session, sends the bytes to the presigned URL, and completes the session for a single file. It does not wait for finalization, so the returned session may still be `committing`.
```ts
uploadFile(bucketId: string, file: UploadFileInput, signal?: AbortSignal): Promise
```
`UploadFileInput` is `{ filename, size, contentType?, path?, data }`, and `size` is always required. Prefer `upload()` for new code: it handles streams, progress, waiting, and failures.
##### createUploadBatch
Creates up to 100 upload sessions in one call. Use it directly when the bytes come from somewhere else, such as a browser (see [Browser usage](#browser-usage)).
```ts
createUploadBatch(bucketId: string, files: UploadInput[]): Promise
```
Route: `POST /api/upload-batches`. Each `UploadInput` is `{ filename, size, contentType?, path? }`; `contentType` defaults to `application/octet-stream` and `path` to the bucket root. The batch's `files` array holds one `UploadSession` per input, in order, each with a short-lived `uploadUrl`.
```ts
interface UploadBatch { id: string; status: string; totalFiles: number; completedFiles?: number; failedFiles?: number; files: UploadSession[] }
interface UploadSession {
id: string; batchId: string; bucketId: string; objectAssetId: string | null;
filename: string; path: string; contentType: string; expectedSize: number;
status: string; revisionNumber: number | null; error: { code: string; message: string } | null;
expiresAt: string; createdAt: string; updatedAt: string; uploadUrl?: string;
}
```
##### getUploadBatch
```ts
getUploadBatch(batchId: string): Promise
```
Route: `GET /api/upload-batches/{batch_id}`. Reads the aggregate status and every session. Sessions end in `ready`, `blocked`, `failed`, or `cancelled`.
##### sendUploadBytes
Sends the bytes for one session, either to its presigned URL or through the API.
```ts
sendUploadBytes(
session: { id: string; uploadUrl?: string; expectedSize?: number },
data: UploadData,
options?: { onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal; via?: "presigned" | "api" },
): Promise
```
Presigned transfers always send `Content-Type: application/octet-stream`, because that is what the URLs are signed for; the stored type comes from the session. Streams are sent with an explicit `Content-Length`, so large files are never buffered. Throws `SteadyLinkError` with status 500 when the session has no `uploadUrl` and `via` is not `api`.
##### completeUpload
```ts
completeUpload(uploadSessionId: string, idempotencyKey?: string): Promise
```
Route: `POST /api/upload-sessions/{id}/complete`. Sends `Idempotency-Key: complete-{uploadSessionId}` unless you pass your own, which makes the call safe to retry. Completing a session that already reached a terminal state returns it unchanged; a different key for the same session returns `409`.
##### cancelUpload
```ts
cancelUpload(uploadSessionId: string): Promise
```
Route: `DELETE /api/upload-sessions/{id}`. Deletes the temporary bytes and releases the reserved storage. Sessions that are already `ready`, `blocked`, or `failed` return `409`.
##### waitForUploads
Polls a batch until the listed sessions (or all of them) reach `ready`, `blocked`, `failed`, or `cancelled`.
```ts
waitForUploads(batchId: string, sessionIds?: string[], options?: WaitOptions): Promise
```
`WaitOptions` is `{ timeoutMs?: number; intervalMs?: number; signal?: AbortSignal }`. Defaults: 300000 ms timeout and a 750 ms interval that grows by 1.5 times up to 3 s. Throws `SteadyLinkTimeoutError` when the deadline passes; the upload keeps processing on the server.
#### Replace files
##### replace
Publishes new bytes as the next revision of an existing file. The asset ID and every link to it stay the same, so pages, emails, and QR codes serve the new file without being edited.
```ts
replace(target: ReplaceTarget, source: UploadSource, options?: ReplaceOptions): Promise
```
**Parameters**
- `target` (string | object): An asset ID (`"3f2a9c1e-..."`), `{ assetId }`, or `{ bucketId, key }`. The SDK method does not parse links; to accept a delivery URL, run it through [parseAssetRef](#parseassetref) first.
- `source` (UploadSource): The new file. Same shape as for `upload()`. `size` is required for streams.
- `options.onProgress`: void">Byte progress.
- `options.signal` (AbortSignal): Cancels the transfer.
With an asset ID, the SDK first calls `getFile()` to find the bucket and key, and throws a 404 `SteadyLinkError` if the asset is not a bucket file. It then calls `createReplacementUpload()`, sends the bytes, and calls `finishReplacement()`.
```ts title="Example"
const result = await steadylink.replace("3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d", await fileFromPath("./price-list-2026.pdf"));
// { replaced: true, version: 4, bucketId: "...", key: "docs/price-list.pdf", assetId: "3f2a9c1e-...", url: "https://cdn.steadylink.io/a/3f2a9c1e-..." }
```
`ReplaceResult` is `{ replaced, version, bucketId, key, assetId, url }`. When you replace by bucket and key, the SDK looks the asset ID up afterwards; `assetId` and `url` are `null` if that lookup fails.
##### replaceFile
Deprecated. Use `replace({ bucketId, key }, file)`.
```ts
replaceFile(bucketId: string, key: string, file: { filename: string; size: number; contentType?: string; data: BodyInit }, signal?: AbortSignal): Promise<{ replaced: boolean; version: number }>
```
##### createReplacementUpload
```ts
createReplacementUpload(bucketId: string, size: number, contentType?: string): Promise<{ uploadUrl: string; tempKey: string }>
```
Route: `POST /api/assets/{bucket_id}/objects/upload-temp`. Returns a presigned URL for the new bytes and a temporary key to pass to `finishReplacement()`.
##### finishReplacement
```ts
finishReplacement(bucketId: string, key: string, tempKey: string, originalFilename?: string): Promise<{ replaced: boolean; version: number }>
```
Route: `POST /api/assets/{bucket_id}/objects/replace`. Scans and commits the temporary bytes as the next revision of `key`. A file caught by the malware scan before commit is rejected with `400` and the current revision keeps serving. If the background scan finds malware after the new revision went current, the link returns `404` until you restore a clean revision. See [The scan window after a replacement](https://steadylink.io/docs/concepts/stable-links#the-scan-window-after-a-replacement).
#### Links and signed links
##### link
Builds the stable delivery URL for an asset. No request is made.
```ts
link(assetId: string, options?: TransformOptions): string
```
The URL is `{cdnUrl}/a/{assetId}`, plus query parameters for any transform options. Without `version` it follows the current revision; with `version` it is pinned to one revision. See [assetUrl](#asseturl) for every option.
```ts
steadylink.link(assetId); // follows the current revision
steadylink.link(assetId, { width: 1200, height: 630, fit: "cover", format: "webp", quality: 80 });
steadylink.link(assetId, { version: 3 }); // always revision 3
```
##### createSignedLink
Creates a revocable, expiring link for a private file.
```ts
createSignedLink(assetId: string, options?: SignedLinkOptions): Promise
```
**SignedLinkOptions**
- `ttlSeconds` (number, default 300): Lifetime in seconds. The API clamps it between 60 and 2,592,000 (30 days).
- `name` (string): Internal label shown in the grant list, up to 200 characters.
- `revision` (number): Pin the grant to one revision. Omit to follow the current revision. A revision that does not exist returns 404.
Route: `POST /api/assets/{asset_id}/signed-url`. Returns `{ id, name, revisionNumber, expiresAt, token, url }`, where `url` is `link(assetId, { token })`. The token is shown only in this response; store the grant `id` if you may need to revoke it.
##### listSignedLinks
```ts
listSignedLinks(assetId: string): Promise<{ items: SignedGrant[] }>
```
Route: `GET /api/assets/{asset_id}/signed-urls`. Up to 200 grants, newest first, each `{ id, name, revisionNumber, expiresAt, revokedAt, createdAt }`. Tokens are not returned.
##### revokeSignedLink
```ts
revokeSignedLink(assetId: string, grantId: string): Promise
```
Route: `DELETE /api/assets/{asset_id}/signed-urls/{grant_id}`. The token stops working immediately.
#### Buckets
##### listBuckets
```ts
listBuckets(limit?: number, cursor?: string): Promise<{ items: Bucket[]; nextCursor: string | null }>
```
Route: `GET /api/assets/`. `limit` defaults to 100. Each `Bucket` has `id`, `name`, `slug`, and optionally `kind`, `createdAt`, `isPrivate`, and `settings`.
##### findBucket
Finds a bucket by ID, slug, or name (case-insensitive) by paging through `listBuckets()`. Returns `undefined` when nothing matches. Checks at most 50 pages of 100.
```ts
findBucket(ref: string): Promise
```
```ts
const bucket = await steadylink.findBucket("marketing");
if (!bucket) throw new Error("No marketing bucket");
```
##### createBucket
```ts
createBucket(name: string, slug?: string): Promise<{ id: string }>
```
Route: `POST /api/assets/`.
##### getBucket
```ts
getBucket(bucketId: string): Promise
```
Route: `GET /api/assets/{bucket_id}`.
##### getAsset
Deprecated. Use `getBucket()` for buckets and `getFile()` for files.
```ts
getAsset(assetId: string): Promise>
```
##### deleteBucket
Deletes a bucket and everything in it. Every link to its files stops working. This cannot be undone.
```ts
deleteBucket(bucketId: string): Promise
```
#### Files
##### listFiles
Lists the folders and files directly inside one folder (not recursive).
```ts
listFiles(bucketId: string, options?: { prefix?: string }): Promise
```
Route: `GET /api/assets/{bucket_id}/objects`. `prefix` is normalized to `a/b/`. Returns `{ prefix, folders, items, revision }`, where `folders` is `{ name, path }[]` and each item is a `FileEntry`:
```ts
interface FileEntry {
id: string; // listing row ID
objectAssetId: string | null; // stable asset ID; null until finalization links the file
key: string; name: string; size: number;
lastModified: string | null; visibility: "public" | "private"; contentType: string | null;
bucketId?: string; bucketName?: string; parent?: string | null;
}
```
##### listAllFiles
Lists files across every bucket in the workspace, with cursor paging.
```ts
listAllFiles(options?: { limit?: number; cursor?: string }): Promise<{ items: FileEntry[]; nextCursor: string | null }>
```
Route: `GET /api/assets/objects`.
##### getFile
Reads a file by its stable asset ID, including the bucket and key it lives at.
```ts
getFile(assetId: string): Promise
```
Route: `GET /api/assets/object/{asset_id}/stat`. `FileDetails` is `{ bucketId?, key, id, objectAssetId, size, contentType, etag?, lastModified, metadata?, assetVersionId?, scanStatus? }`.
##### statFile
Reads a file by bucket and key. Same return shape as `getFile()`.
```ts
statFile(bucketId: string, key: string): Promise
```
Route: `GET /api/assets/{bucket_id}/objects/stat`.
##### setVisibility
```ts
setVisibility(bucketId: string, key: string, visibility: "public" | "private" | "inherit"): Promise<{ updated: boolean }>
```
Route: `POST /api/assets/{bucket_id}/objects/visibility`. `inherit` removes the file-level setting so the bucket default applies. Private files need a [signed link](#createsignedlink).
##### deleteFile
Deletes a file, all of its revisions, and its cached transforms. Its link stops working.
```ts
deleteFile(bucketId: string, key: string): Promise<{ deleted: boolean }>
```
##### renameFile
Moves a file to a new key in the same bucket. The asset ID, revisions, and link do not change.
```ts
renameFile(bucketId: string, fromKey: string, toKey: string): Promise<{ renamed: boolean }>
```
Returns `409` when `toKey` already exists.
##### createFolder
```ts
createFolder(bucketId: string, path: string): Promise
```
Route: `POST /api/assets/{bucket_id}/folders`. Creates an empty folder marker. You do not need it before uploading: uploading to `a/b/file.png` creates the folders implicitly.
#### Revisions
##### listVersions
```ts
listVersions(assetId: string): Promise
```
Route: `GET /api/assets/{asset_id}/versions`. Retained revisions, newest first:
```ts
interface Revision {
id: string; versionNumber: number; storageKey?: string;
byteSize: number | null; width: number | null; height: number | null; mime: string | null; hash: string | null;
label: string | null; note: string | null; createdBy?: string | null;
isCurrent: boolean; scanStatus: string | null; metadataStatus: string | null; createdAt: string;
}
```
##### updateVersion
Sets or clears a revision's label and note. Pass `null` to clear a field.
```ts
updateVersion(assetId: string, version: number, changes: { label?: string | null; note?: string | null }): Promise<{ id: string; versionNumber: number; label: string | null; note: string | null }>
```
##### promoteVersion
Makes a retained revision current again. The link keeps working and now serves that revision.
```ts
promoteVersion(assetId: string, version: number): Promise<{ promoted: boolean; versionNumber: number; revisionId: string }>
```
Route: `POST /api/assets/{asset_id}/versions/{version}/promote`.
##### rollback
Alias for `promoteVersion()`.
```ts
rollback(assetId: string, version: number): Promise<{ promoted: boolean; versionNumber: number; revisionId: string }>
```
##### deleteVersion
Deletes one revision's bytes and cached transforms and releases its storage.
```ts
deleteVersion(assetId: string, version: number, options?: { force?: boolean }): Promise
```
Deleting the current revision returns `400` unless `force: true`. With `force`, the highest remaining revision becomes current; links pinned to the deleted revision stop working.
#### Collections
Collections are ordered sets of files published as a portal at `{appUrl}/c/{slug}`. Every method that returns a collection adds a `url` field with that portal address.
```ts
interface CollectionInput {
name: string;
description?: string;
accessMode?: "public" | "private" | "password" | "expiring";
password?: string; // for accessMode "password"
expiresAt?: string; // ISO 8601, for accessMode "expiring"
noindex?: boolean;
}
```
A `Collection` adds `id`, `slug`, `expiresAt`, `noindex`, `viewCount`, `downloadCount`, `itemCount`, `createdAt`, `updatedAt`, `url`, and, when read individually, `branding` and `items`.
##### listCollections
```ts
listCollections(): Promise<{ items: Collection[] }>
```
##### createCollection
```ts
createCollection(input: CollectionInput): Promise
```
```ts
const kit = await steadylink.createCollection({ name: "Press kit", accessMode: "public" });
console.log(kit.url); // https://steadylink.io/c/press-kit
```
##### getCollection
```ts
getCollection(collectionId: string): Promise
```
##### updateCollection
```ts
updateCollection(collectionId: string, changes: Partial): Promise
```
##### deleteCollection
```ts
deleteCollection(collectionId: string): Promise
```
Deletes the collection and its portal. The files themselves are not deleted.
##### addToCollection
```ts
addToCollection(collectionId: string, assetId: string, version?: number): Promise>
```
Without `version` the item follows the file's current revision, so a replacement shows up in the portal. With `version` it stays on that revision.
##### removeFromCollection
```ts
removeFromCollection(collectionId: string, itemId: string): Promise
```
`itemId` is the collection item ID, not the asset ID.
##### pinCollectionItem
```ts
pinCollectionItem(collectionId: string, itemId: string, version: number | null): Promise>
```
Pins an item to a revision, or pass `null` to follow the current revision again.
##### reorderCollection
```ts
reorderCollection(collectionId: string, itemIds: string[]): Promise>
```
Route: `PUT /api/collections/{collection_id}/items/order`. Send every current item ID exactly once, in the new order.
#### Platform helpers
These wrap the workspace-level routes under `/api/platform`. With an API key, reads need the `workspace:read` scope and writes need `workspace:admin`, including `createMigration()` and `setFocalPoint()`. With a user token, the user must be a workspace admin for everything except `setFocalPoint()` and the migration routes. Details are in the [Platform API reference](https://steadylink.io/docs/reference/api/platform).
##### createMigration
Creates a migration record and an upload batch of up to 100 sessions, keeping each file's `path`.
```ts
createMigration(bucketId: string, files: UploadInput[]): Promise<{ id: string; status: string; uploadBatch: UploadBatch }>
```
Send the bytes for each session in `uploadBatch.files` and complete them as with `createUploadBatch()`. When the batch finishes, a [`migration.completed`](https://steadylink.io/docs/reference/webhooks) webhook event fires.
##### listMigrations
```ts
listMigrations(): Promise<{ items: Array> }>
```
The 50 most recent migrations, each `{ id, bucketId, uploadBatchId, status, totalFiles, createdAt }`.
##### setFocalPoint
Sets the default crop position for `fit: "cover"` transforms of an image.
```ts
setFocalPoint(assetId: string, x: number, y: number): Promise<{ x: number; y: number }>
```
`x` and `y` are fractions from 0 (left or top) to 1 (right or bottom), stored to four decimal places. Values outside that range return `400`.
##### listDeliveryDomains
```ts
listDeliveryDomains(): Promise<{ items: Array> }>
```
##### addDeliveryDomain
```ts
addDeliveryDomain(hostname: string): Promise>
```
Registers a hostname such as `media.example.com` and returns the TXT record to create (`_steadylink.{hostname}`). Hostnames under `steadylink.io` and hostnames another workspace already claimed are rejected.
##### verifyDeliveryDomain
```ts
verifyDeliveryDomain(domainId: string): Promise>
```
Checks the TXT record and marks the domain verified when it matches.
##### getDeliveryPolicy
```ts
getDeliveryPolicy(): Promise
```
Returns `{ mode, countries, storageRegion, availableStorageRegions }`. `mode` is `all`, `allow`, or `deny`.
##### setDeliveryPolicy
```ts
setDeliveryPolicy(policy: { mode: "all" | "allow" | "deny"; countries: string[]; storageRegion?: string }): Promise
```
`countries` are two-letter ISO codes and must not be empty for `allow` or `deny`. `storageRegion` can only be the workspace's current region; any other value returns `422 unsupported_storage_region`.
##### createWebhook
```ts
createWebhook(url: string, events: WebhookEvent[]): Promise>
```
Registers a generic HTTPS receiver. The response includes the signing `secret`, shown only once. `WebhookEvent` is `"asset.revision.published" | "asset.scan.completed" | "asset.delivery.threshold" | "migration.completed"`. The SDK always creates a generic destination; to register a Discord, Slack, or Teams destination, call `request()` with a `destinationType` (see [Webhooks](https://steadylink.io/docs/reference/webhooks)).
##### listWebhooks
```ts
listWebhooks(): Promise<{ availableEvents: WebhookEvent[]; items: Array> }>
```
##### testWebhook
Sends a test event to the endpoint right away and waits for its response.
```ts
testWebhook(webhookId: string): Promise>
```
The API returns `{ delivered: true, responseStatus, deliveryId }` on a 2xx from your endpoint and `502 webhook_delivery_failed` otherwise. The 0.2.0 type declares `{ queued: boolean }`, which does not match; read the fields above.
##### configureSso
Saves the workspace's OpenID Connect connection.
```ts
configureSso(configuration: { issuer: string; clientId: string; clientSecret?: string; emailDomains: string[]; enabled: boolean; enforce: boolean }): Promise>
```
`clientSecret` is required the first time. Enforcement is rejected with `409 sso_test_required` until a test login through the saved connection succeeds, and SSO requires a Business or Enterprise plan. See [Single sign-on](https://steadylink.io/docs/security/single-sign-on).
#### Low-level calls
##### request
Sends an authenticated JSON request to any API route. Every other method is built on it, so it is the escape hatch for routes the SDK does not wrap yet.
```ts
request(path: string, options?: RequestOptions): Promise
```
`path` starts with `/api/`. `RequestOptions` is the standard `RequestInit` plus `retry?: boolean` (set `false` to disable retries for this call) and `timeoutMs?: number` (override the client timeout; `0` disables it). The SDK adds `Accept: application/json`, the credential header, `X-Workspace-Id` when configured, and `Content-Type: application/json` when there is a body. A `204` resolves to `undefined`; a non-JSON body resolves to its text.
```ts
const webhook = await steadylink.request<{ id: string; secret?: string }>("/api/platform/webhooks", {
method: "POST",
body: JSON.stringify({
url: "https://hooks.slack.com/services/T000/B000/XXXX",
destinationType: "slack",
events: ["asset.revision.published"],
}),
});
```
##### uploadToUrl
Uploads bytes to any presigned storage URL without sending SteadyLink credentials.
```ts
uploadToUrl(uploadUrl: string, body: UploadData | BodyInit, contentType?: string, signal?: AbortSignal, options?: { size?: number; onProgress?: (loaded: number, total: number) => void }): Promise
```
`contentType` defaults to `application/octet-stream`. Leave it at the default for SteadyLink upload URLs: they are signed for that type, and storage rejects any other value with a signature error. Pass `options.size` for streams. Storage error XML is translated into a `SteadyLinkError` message such as `Storage rejected the upload (HTTP 403): SignatureDoesNotMatch`.
#### Standalone exports
These do not need a client, so they work in browser code without any credential.
##### assetUrl
```ts
assetUrl(assetId: string, options?: TransformOptions, origin?: string): string
```
Builds `{origin}/a/{assetId}` with transform query parameters. `origin` defaults to `https://cdn.steadylink.io`. Throws a `TypeError` for an empty `assetId`.
**TransformOptions**
- `version` (number): Query `v`. Pin to one retained revision. Pinned URLs never change content, so they can be cached for a long time.
- `width` (number): Query `w`, in pixels. Rounded.
- `height` (number): Query `h`, in pixels. Rounded.
- `format` ("webp" | "jpg" | "png"): Query `fm`.
- `quality` (number): Query `q`. Clamped to 30 through 95.
- `fit` ("cover" | "contain" | "inside" | "outside"): Query `fit`.
- `focus` ("center" | "auto"): Query `focus`. `auto` picks high-detail framing when `fit` is `cover`.
- `focalPoint` (object): `{ x, y }`, each clamped to 0 through 1. Queries `fp-x` and `fp-y`. Use with `fit: "cover"`.
- `token` (string): Query `token`. A signed access token for a private file.
Transforms apply to raster images. See [Image transformations](https://steadylink.io/docs/guides/image-transformations) for how each parameter behaves.
##### applyTransform
```ts
applyTransform(params: URLSearchParams, options?: TransformOptions): URLSearchParams
```
Writes the transform options into an existing `URLSearchParams`, overwriting keys that are already there, and returns it.
##### parseAssetRef
```ts
parseAssetRef(value: string): { assetId: string; params: URLSearchParams; origin?: string } | undefined
```
Extracts an asset ID from a bare ID, an `/a/{id}` path, or a full delivery URL. `params` holds the URL's query string and `origin` is set only for absolute URLs. Returns `undefined` for anything else.
```ts
parseAssetRef("https://media.example.com/a/3f2a9c1e-...?v=3");
// { assetId: "3f2a9c1e-...", params: URLSearchParams { v: "3" }, origin: "https://media.example.com" }
```
##### contentTypeFor
```ts
contentTypeFor(filename: string): string
```
Guesses a MIME type from the extension (common image, audio, video, document, archive, and font types) and falls back to `application/octet-stream`.
##### joinFolder
```ts
joinFolder(...parts: Array): string
```
Joins folder segments into `a/b/` form, or `""` for the bucket root. Backslashes become slashes, empty and `.` segments are dropped, and `..` throws a `TypeError`.
##### Constants
`DEFAULT_API_URL` (`https://api.steadylink.io`), `DEFAULT_CDN_URL` (`https://cdn.steadylink.io`), and `DEFAULT_APP_URL` (`https://steadylink.io`).
##### Types
Every interface on this page is exported as a type, including `SteadyLinkClientOptions`, `RequestOptions`, `Bucket`, `FileEntry`, `FileListing`, `FileDetails`, `UploadSource`, `UploadOptions`, `UploadedFile`, `UploadBatch`, `UploadSession`, `ReplaceTarget`, `ReplaceResult`, `Revision`, `SignedLinkOptions`, `SignedLink`, `Collection`, `CollectionInput`, `DeliveryPolicy`, `WebhookEvent`, `TransformOptions`, `ImageFit`, `ImageFormat`, `UploadData`, and `ByteProgress`.
#### Node.js helpers
##### fileFromPath
Reads a file from disk as an upload source. The stream is opened lazily when the upload starts, so multi-gigabyte files never sit in memory and unused sources never hold a file descriptor.
```ts
import { fileFromPath } from "@steadylink/sdk/node";
fileFromPath(path: string, options?: { filename?: string; contentType?: string; path?: string }): Promise
```
`filename` defaults to the base name, `contentType` to a guess from that name, and `options.path` sets the destination folder. Throws a `TypeError` when `path` is not a regular file.
```ts
const [video] = await steadylink.upload(bucketId, await fileFromPath("./keynote.mp4"), {
onProgress: ({ loaded, total }) => process.stdout.write(`\r${((loaded / total) * 100).toFixed(1)}%`),
});
```
#### next/image loader
`@steadylink/sdk/next-loader` turns an asset ID into a resized delivery URL for each `srcset` width, so you can keep the asset ID in your CMS and replace the image at any time.
```js title="steadylink-loader.js"
export { default } from "@steadylink/sdk/next-loader";
```
```js title="next.config.js"
module.exports = {
images: { loader: "custom", loaderFile: "./steadylink-loader.js" },
};
```
```tsx title="page.tsx"
// https://cdn.steadylink.io/a/3f2a9c1e-...?w=1200&fm=webp&q=75
```
##### default export
The default loader. `src` can be an asset ID, an `/a/{id}` path, or a full delivery URL; a URL keeps its host and query (such as `v=3`). Any other `src`, such as `/logo.png`, is returned unchanged, so local images keep working. The origin is the URL's own origin, then `NEXT_PUBLIC_STEADYLINK_CDN_URL`, then `https://cdn.steadylink.io`.
##### createSteadyLinkLoader
```ts
createSteadyLinkLoader(options?: SteadyLinkLoaderOptions): (props: { src: string; width: number; quality?: number }) => string
```
**SteadyLinkLoaderOptions**
- `origin` (string): Delivery origin, for example a custom domain. Defaults as described above.
- `format` ("webp" | "jpg" | "png" | false, default webp): Output format. `false` keeps the source format. An `fm` already in the `src` URL wins.
- `quality` (number, default 75): Used when `next/image` does not pass a quality.
- `fit` (ImageFit): Added unless the `src` URL already has `fit`.
The types `ImageLoaderProps` and `SteadyLinkLoaderOptions` are exported from the same path. The [Next.js images guide](https://steadylink.io/docs/guides/nextjs-images) walks through a full setup.
#### Errors
Every failure is one of four classes, all exported from `@steadylink/sdk`.
##### SteadyLinkError
A non-success HTTP response from the API or from a presigned storage URL.
**Fields**
- `status` (number): HTTP status.
- `message` (string): The response's `message` when it is a string, such as `Upload session expired`. When the API returns a structured error, the message is `SteadyLink request failed with HTTP {status}`.
- `code` (string | undefined): A string code read from `detail.code` or `code`. API error bodies carry the numeric status in `code`, so for API errors this is `undefined` in 0.2.0; read the machine-readable code from `detail` as shown below. Storage errors from presigned uploads do set it, for example `SignatureDoesNotMatch`.
- `detail` (unknown): The parsed response body. For API errors this is the full error body, `{ code, message, requestId }`, where `message` is either a string or an object such as `{ code: "insufficient_scope", required: "assets:write" }`.
- `requestId` (string | undefined): From the `X-Request-Id` header or the body's `requestId`. Include it when you contact support.
- `retryAfterMs` (number | undefined): From `Retry-After`, in milliseconds.
To branch on the API's error code, read it from the body:
```ts
function apiErrorCode(error: SteadyLinkError): string | undefined {
if (error.code) return error.code;
const message = (error.detail as { message?: unknown } | undefined)?.message;
return message && typeof message === "object" && "code" in message ? String(message.code) : undefined;
}
// apiErrorCode(error) === "insufficient_scope", "api_write_limit_exceeded", "storage_quota_exceeded", ...
```
##### SteadyLinkNetworkError
No HTTP response arrived: DNS, TLS, connection reset, timeout, or cancellation through an `AbortSignal`. The original error is on `cause`.
##### SteadyLinkTimeoutError
`waitForUploads()` (or `upload()` while waiting) hit its deadline. The upload is still processing on the server; poll `getUploadBatch()` later.
##### SteadyLinkUploadError
One or more files in an `upload()` call failed and `throwOnError` was not `false`. `results` holds every file's `UploadedFile`, so you can tell which succeeded.
```ts
import { SteadyLinkError, SteadyLinkNetworkError, SteadyLinkUploadError, type UploadedFile } from "@steadylink/sdk";
try {
await steadylink.upload(bucketId, files);
} catch (error) {
if (error instanceof SteadyLinkUploadError) {
for (const file of error.results as UploadedFile[]) if (file.error) console.error(file.key, file.error.message);
} else if (error instanceof SteadyLinkError) {
console.error(error.status, apiErrorCode(error), error.requestId);
} else if (error instanceof SteadyLinkNetworkError) {
console.error(error.message, error.cause);
}
}
```
The status codes and error codes themselves are listed on [Errors](https://steadylink.io/docs/reference/errors).
#### Retries
JSON calls are retried up to `maxRetries` times (2 by default) when both of these hold:
- The method is `GET`, `HEAD`, or `OPTIONS`, or the request carries an `Idempotency-Key` header. `completeUpload()` always sends one.
- The request failed at the network level, or the response was `429`, `502`, `503`, or `504`.
The wait is the `Retry-After` value when the response has one, otherwise 250 ms, 500 ms, 1 s, and so on. Other writes, such as `replace()` or `deleteFile()`, are never retried automatically, because repeating them could apply the change twice. Byte uploads to storage are never retried either. Cancelling through an `AbortSignal` stops retries.
#### Browser usage
Do not put an API key in browser code. Anyone can read it from the bundle, and it carries the full scopes of the key. Instead, split the upload between your server and the browser:
**Server: create the session**
```ts
const batch = await steadylink.createUploadBatch(bucketId, [{ filename, size, contentType }]);
return Response.json({ id: batch.files[0].id, uploadUrl: batch.files[0].uploadUrl });
```
**Browser: send the bytes**
```ts
await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: file });
```
The presigned URL is temporary and only accepts this one upload. It never carries SteadyLink credentials.
**Server: complete it**
```ts
await steadylink.completeUpload(sessionId);
```
When the SDK itself runs in a browser with a short-lived `accessToken`, `upload()` and `replace()` with `onProgress` use `XMLHttpRequest` so you get real upload progress events. Building links with `assetUrl()` needs no credential at all. For a drop-in UI, see the [upload widget](https://steadylink.io/docs/guides/upload-widget).
#### Next steps
- [Browser uploads](https://steadylink.io/docs/guides/browser-uploads): The full server and browser flow, with progress and error handling.
- [Upload and replace](https://steadylink.io/docs/guides/upload-and-replace): How uploads, scanning, and replacements behave end to end.
- [CLI](https://steadylink.io/docs/reference/cli): The same operations from a terminal or CI job.
- [Errors](https://steadylink.io/docs/reference/errors): Status codes, error bodies, and rate-limit headers.
### Python SDK
Source: https://steadylink.io/docs/reference/python-sdk
`steadylink` is the official Python client for the SteadyLink API. It is synchronous, uses only the standard library, and is a good fit for Django or Flask views, background workers, data pipelines, and one-off scripts. This page lists every public method with its exact arguments and what it sends, so you can predict what each call does before you run it.
Version 0.1.0 covers uploads, replacements, and the platform routes. For anything it does not wrap, such as listing files or minting signed links, use [`request()`](#request) with any route from the [REST API](https://steadylink.io/docs/reference/api).
#### Install
Requires Python 3.10 or later.
```bash
python -m pip install steadylink
```
The package includes a `py.typed` marker, so mypy and Pyright pick up its inline annotations.
#### Create a client
```python
import os
from steadylink import SteadyLink
client = SteadyLink(api_key=os.environ["STEADYLINK_API_KEY"])
buckets = client.list_buckets()["items"]
```
Create a key in **Dashboard > Developers** with `assets:read` and `assets:write`. Keep it on the server: in environment variables or a secret manager, never in source control or a client app.
All arguments are keyword-only. The constructor raises `ValueError` when you pass both `api_key` and `access_token`, or neither.
**Constructor arguments**
- `api_key` (str): A workspace API key, sent as `X-API-Key`. Use this or `access_token`.
- `access_token` (str): A user access token, sent as `Authorization: Bearer`. Use this or `api_key`.
- `workspace_id` (str): Sent as `X-Workspace-Id`. Only needed with an `access_token` for a user in several workspaces; API keys already belong to one workspace.
- `base_url` (str, default https://api.steadylink.io): API origin. A trailing slash is removed.
- `max_retries` (int, default 2): How many times a retryable request is retried. See [Retries](#retries).
- `transport` (callable): Replaces the JSON transport. Called as `transport(method, url, headers, body)` and must return `(status, raw_bytes)` or `(status, raw_bytes, headers)`. Use it for tests, proxies, or a different HTTP library.
- `binary_transport` (callable): Replaces the presigned-upload transport. Called as `binary_transport(url, body, content_type)` with the same return shape.
The default transports use `urllib`. JSON calls time out after 30 seconds and presigned uploads after 60 seconds. A large file on a slow connection can exceed 60 seconds; supply your own `binary_transport` with a longer timeout for big uploads.
Every request sends `Accept: application/json` and `User-Agent: steadylink-python/0.1.0`. Methods return the decoded JSON response as plain `dict` and `list` values.
#### Uploads
**Warning: Content type and presigned URLs.**
Upload URLs are signed for `Content-Type: application/octet-stream`. In 0.1.0, `upload_file()` and `replace_file()` send their `content_type` argument as the `Content-Type` of the storage request, so any other value makes storage reject the upload with HTTP 403 (`SignatureDoesNotMatch`). Leave `content_type` at its default with these helpers, or use the [three-step upload](#upload-with-a-specific-content-type) to store a specific type.
##### upload_file
Creates an upload session, sends the bytes to the presigned storage URL, and completes the session.
```python
upload_file(bucket_id: str, filename: str, body: bytes, content_type: str = "application/octet-stream", path: str = "") -> dict
```
**Arguments**
- `bucket_id` (str): Destination bucket ID.
- `filename` (str): File name, including extension.
- `body` (bytes): The file contents. The whole file is held in memory.
- `content_type` (str, default application/octet-stream): Declared type for the session and the `Content-Type` of the storage request. See the warning above.
- `path` (str, default ""): Folder inside the bucket, for example `"campaign/"`.
Returns the completed upload session. Completion queues finalization, so the session's `status` is usually `committing` and `objectAssetId` may still be `null`. Poll `GET /api/upload-batches/{batchId}` with [`request()`](#request) until the session is `ready` to get the asset ID. Uploading to a key that already exists adds a new revision to that file.
```python
from pathlib import Path
path = Path("campaign-hero.webp")
session = client.upload_file(
"8b1f6c2e-4a7d-4f0e-b3c9-2d5e7a9f1c04",
filename=path.name,
body=path.read_bytes(),
path="campaign/",
)
print(session["id"], session["status"])
```
##### create_upload_batch
Creates up to 100 upload sessions in one call. Each session in the response's `files` list has an `id` and a presigned `uploadUrl`.
```python
create_upload_batch(bucket_id: str, files: list[UploadFile]) -> dict
```
`UploadFile` is a frozen dataclass exported by the package:
```python
@dataclass(frozen=True)
class UploadFile:
filename: str
size: int
contentType: str = "application/octet-stream"
path: str = ""
```
Route: `POST /api/upload-batches`.
##### upload_to_url
Uploads bytes to a presigned URL with a `PUT`. It never attaches SteadyLink credentials, so it is safe to use with URLs your server hands out.
```python
upload_to_url(upload_url: str, body: bytes, content_type: str = "application/octet-stream") -> None
```
Raises `SteadyLinkError` for a non-2xx response from storage and `SteadyLinkNetworkError` when storage cannot be reached. It is not retried.
##### complete_upload
```python
complete_upload(session_id: str, idempotency_key: str | None = None) -> dict
```
Route: `POST /api/upload-sessions/{session_id}/complete`. Sends `Idempotency-Key: complete-{session_id}` unless you pass a key, which makes it safe to retry and lets the client retry it automatically on `429`, `502`, `503`, and `504`.
##### cancel_upload
```python
cancel_upload(session_id: str) -> None
```
Route: `DELETE /api/upload-sessions/{session_id}`. Deletes the temporary bytes and releases the reserved storage. Sessions already `ready`, `blocked`, or `failed` return `409`.
##### Upload with a specific content type
To store a type other than `application/octet-stream`, declare it on the session and send the bytes with the default type:
```python
from pathlib import Path
from steadylink import UploadFile
path = Path("campaign-hero.webp")
data = path.read_bytes()
batch = client.create_upload_batch(bucket_id, [UploadFile(path.name, len(data), "image/webp", "campaign/")])
session = batch["files"][0]
client.upload_to_url(session["uploadUrl"], data) # Content-Type: application/octet-stream
client.complete_upload(session["id"])
```
#### Replacements
A replacement publishes new bytes as the next revision of an existing file. The asset ID and every link to it stay the same.
##### replace_file
```python
replace_file(bucket_id: str, key: str, filename: str, body: bytes, content_type: str = "application/octet-stream") -> dict
```
Calls `create_replacement_upload()`, sends the bytes with `upload_to_url()`, then calls `finish_replacement()`. Returns `{"replaced": True, "version": 4}`. Leave `content_type` at its default (see the warning under [Uploads](#uploads)). To declare a specific type for the new revision, run the three steps yourself:
```python
pending = client.create_replacement_upload(bucket_id, len(data), "image/webp")
client.upload_to_url(pending["uploadUrl"], data) # Content-Type: application/octet-stream
client.finish_replacement(bucket_id, "campaign/campaign-hero.webp", pending["tempKey"], "campaign-hero-v2.webp")
```
```python
from pathlib import Path
replacement = Path("campaign-hero-v2.webp")
result = client.replace_file(
bucket_id,
"campaign/campaign-hero.webp",
filename=replacement.name,
body=replacement.read_bytes(),
)
print(result["version"])
```
A file caught by the malware scan before commit is rejected with `400` and the current revision keeps serving. If the background scan finds malware after the new revision went current, the link returns `404` until you restore a clean revision. See [The scan window after a replacement](https://steadylink.io/docs/concepts/stable-links#the-scan-window-after-a-replacement).
##### create_replacement_upload
```python
create_replacement_upload(bucket_id: str, size: int, content_type: str = "application/octet-stream") -> dict
```
Route: `POST /api/assets/{bucket_id}/objects/upload-temp`. Returns `{"uploadUrl": ..., "tempKey": ...}`.
##### finish_replacement
```python
finish_replacement(bucket_id: str, key: str, temp_key: str, original_filename: str | None = None) -> dict
```
Route: `POST /api/assets/{bucket_id}/objects/replace`. Commits the temporary bytes as the next revision of `key`.
#### Buckets and assets
##### list_buckets
```python
list_buckets(limit: int = 100) -> dict
```
Route: `GET /api/assets/`. Returns `{"items": [...], "nextCursor": ...}`. The method has no cursor argument; to page past the first `limit` buckets, call `request("GET", f"/api/assets/?limit=100&cursor={cursor}")`.
##### get_asset
```python
get_asset(asset_id: str) -> dict
```
Route: `GET /api/assets/{asset_id}`. Reads a bucket or asset record.
#### Platform helpers
These wrap routes under `/api/platform`. With an API key, reads need `workspace:read` and writes need `workspace:admin`, including `create_migration()` and `set_focal_point()`. Field details are in the [Platform API reference](https://steadylink.io/docs/reference/api/platform).
##### create_migration
```python
create_migration(bucket_id: str, files: list[UploadFile]) -> dict
```
Creates a migration record with an upload batch of up to 100 sessions and returns `{"id", "status", "uploadBatch"}`. Send and complete each session in `uploadBatch["files"]` as above. A `migration.completed` [webhook](https://steadylink.io/docs/reference/webhooks) fires when the batch finishes.
##### list_migrations
```python
list_migrations() -> dict
```
The 50 most recent migrations, each with `id`, `bucketId`, `uploadBatchId`, `status`, `totalFiles`, and `createdAt`.
##### set_focal_point
```python
set_focal_point(asset_id: str, x: float, y: float) -> dict
```
Sets the default crop position for cover transforms. `x` and `y` run from 0 to 1. Returns `{"x": 0.42, "y": 0.31}`.
##### list_delivery_domains
```python
list_delivery_domains() -> dict
```
##### add_delivery_domain
```python
add_delivery_domain(hostname: str) -> dict
```
Returns the domain with the TXT record to create at `_steadylink.{hostname}`.
##### verify_delivery_domain
```python
verify_delivery_domain(domain_id: str) -> dict
```
##### get_delivery_policy
```python
get_delivery_policy() -> DeliveryPolicy
```
Returns `{"mode", "countries", "storageRegion", "availableStorageRegions"}`.
##### set_delivery_policy
```python
set_delivery_policy(policy: DeliveryPolicy) -> DeliveryPolicy
```
`DeliveryPolicy` is a `TypedDict` exported by the package: `mode` (`"all"`, `"allow"`, or `"deny"`) and `countries` (two-letter ISO codes) are required, `storageRegion` is optional and can only be the current region.
```python
client.set_delivery_policy({"mode": "deny", "countries": ["KP", "RU"]})
```
##### create_webhook
```python
create_webhook(url: str, events: list[str]) -> dict
```
Registers a generic HTTPS receiver for up to 10 event types. The response includes the signing `secret`, returned only once. For a Discord, Slack, or Teams destination, call `request()` with `destinationType`; see [Webhooks](https://steadylink.io/docs/reference/webhooks).
##### list_webhooks
```python
list_webhooks() -> dict
```
Returns `{"availableEvents": [...], "items": [...]}`.
##### test_webhook
```python
test_webhook(webhook_id: str) -> dict
```
Sends a test event and waits for your endpoint. Returns `{"delivered": True, "responseStatus": 200, "deliveryId": ...}`; a non-2xx answer raises `SteadyLinkError` with status 502, and `api_error_code(error)` returns `webhook_delivery_failed`.
#### Low-level calls
##### request
Sends an authenticated JSON request to any route. Every other method is built on it.
```python
request(method: str, path: str, payload: dict | None = None, *, idempotency_key: str | None = None, retry: bool = True) -> Any
```
`payload` is sent as compact JSON with `Content-Type: application/json`. `idempotency_key` adds an `Idempotency-Key` header and makes a write eligible for retries. `retry=False` turns retries off for this call. Returns the decoded JSON, the text for a non-JSON body, or `None` for an empty body.
```python
signed = client.request("POST", f"/api/assets/{asset_id}/signed-url?ttl=3600&name=client+preview")
print(signed["token"], signed["expiresAt"])
```
#### Errors
Both error classes subclass `RuntimeError` and are exported from `steadylink`.
##### SteadyLinkError
A non-success HTTP response from the API or from storage.
**Attributes**
- `status` (int): HTTP status.
- `code` (Any): The `code` field of `detail`. API error bodies put the numeric HTTP status there, so for API errors in 0.1.0 this is an `int` equal to `status`, not a string code. Read the machine-readable code as shown below.
- `detail` (Any): The parsed response body. API errors look like `{"code": 403, "message": ..., "requestId": ...}`, where `message` is a string or an object such as `{"code": "insufficient_scope", "required": "assets:write"}`.
- `request_id` (str | None): From the `X-Request-Id` header or the body's `requestId`.
- `retry_after` (float | None): Seconds from the `Retry-After` header.
`str(error)` is the body's `message` when it is a string, such as `Upload session expired`, else `SteadyLink request failed with HTTP {status}`.
```python
def api_error_code(error: SteadyLinkError) -> str | None:
message = error.detail.get("message") if isinstance(error.detail, dict) else None
return message.get("code") if isinstance(message, dict) else None
```
##### SteadyLinkNetworkError
The request never got an HTTP response: DNS failure, refused connection, or timeout. The original exception is chained as `__cause__`.
```python
from steadylink import SteadyLinkError, SteadyLinkNetworkError
try:
client.list_buckets()
except SteadyLinkError as error:
print(error.status, api_error_code(error), error.request_id)
except SteadyLinkNetworkError as error:
print(str(error), error.__cause__)
```
Status codes and error codes are listed on [Errors](https://steadylink.io/docs/reference/errors).
#### Retries
A request is retried up to `max_retries` times when both of these hold:
- The method is `GET`, `HEAD`, or `OPTIONS`, or the call passed an `idempotency_key`. `complete_upload()` always does.
- The request raised a network error, or the response was `429`, `502`, `503`, or `504`.
The client waits for the `Retry-After` value when the response has one, otherwise 0.25 s, 0.5 s, 1 s, and so on, each with up to 0.1 s of random jitter. Other writes and presigned uploads are never retried automatically, because repeating them could apply a change twice.
#### Next steps
- [REST API](https://steadylink.io/docs/reference/api): Routes the Python client does not wrap yet, callable through `request()`.
- [Uploads](https://steadylink.io/docs/concepts/uploads): Sessions, finalization, scanning, and what each status means.
- [Webhooks](https://steadylink.io/docs/reference/webhooks): Receive and verify events, with a Python verification snippet.
### CLI
Source: https://steadylink.io/docs/reference/cli
The SteadyLink CLI uploads files, replaces them without changing their links, and prints plain, resized, or signed links from a terminal or a CI job. Use this page to look up the exact behavior of a command or flag, or to script against its JSON output and exit codes.
The CLI is `@steadylink/cli` 0.2.0, built on the [TypeScript SDK](https://steadylink.io/docs/reference/sdk). It needs Node.js 18 or later.
#### Install
Run it without installing through the unscoped `steadylink` package, which is an alias that depends on `@steadylink/cli` 0.2.0:
```bash
npx steadylink login
npx steadylink upload ./hero.webp --bucket marketing --public
```
Or install it globally to get the `steadylink` command:
```bash
npm install -g @steadylink/cli
steadylink --version
```
In CI, pin the version so a new release cannot change behavior under you: `npx @steadylink/cli@0.2.0 upload ...`.
#### Quick reference
| Command | What it does |
| --- | --- |
| [`login`](#login) | Verifies an API key and saves it. |
| [`logout`](#logout) | Removes the saved key. |
| [`upload`](#upload) | Uploads files and folders, and prints one stable link per file. Alias: `migrate`. |
| [`replace`](#replace) | Publishes a new revision of an existing file. The link does not change. |
| [`ls`](#ls) | Lists buckets, or the contents of a bucket folder. Alias: `list`. |
| [`link`](#link) | Prints a delivery link, optionally resized or signed. |
| [`rm`](#rm) | Deletes a file and every revision. Alias: `delete`. |
| [`versions`](#versions) | Lists a file's revisions. |
| [`rollback`](#rollback) | Makes an older revision current again. |
| [`inspect`](#inspect) | Prints file details as JSON. |
| [`focal`](#focal) | Sets the crop focal point for an image. |
| `help` | Prints usage. Same as `--help` or `-h`, or running with no command. |
| `version` | Prints `0.2.0`. Same as `--version` or `-v`. With `--json`: `{"version":"0.2.0"}`. |
#### Addressing files and buckets
Commands that take a file accept any of these forms:
- An asset ID: `3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d`.
- A delivery link: `https://cdn.steadylink.io/a/3f2a9c1e-...` or `/a/3f2a9c1e-...`. Any host works, so custom-domain links do too.
- `bucket:key`, where `bucket` is a bucket ID, slug, or name and `key` is the path inside it: `marketing:campaign/hero.webp`. A leading slash on the key is ignored.
- A bare key together with `--bucket`: `steadylink rm campaign/hero.webp --bucket marketing`.
Buckets are matched by ID, slug, or name, case-insensitively. A value shaped like a UUID is used as an ID without a lookup.
`versions`, `rollback`, `inspect`, and `focal` take an asset ID only.
#### Global options
These work with every command.
**Global options**
- `--json` (flag): Print machine-readable JSON to stdout and suppress progress. Errors are printed to stdout as JSON too; see [JSON output](#json-output).
- `--quiet, -q` (flag): Hide progress and summary lines on stderr. Results still go to stdout.
- `--api-key` (string): Use this key for this command only. Takes precedence over `STEADYLINK_API_KEY` and the saved login.
- `--api-url` (string, default https://api.steadylink.io): API origin. `--api` is an accepted alias.
- `--cdn-url` (string, default https://cdn.steadylink.io): Delivery origin for printed links, for example a custom delivery domain.
- `--help, -h` (flag): Print usage and exit 0.
- `--version, -v` (flag): Print the version and exit 0.
##### How flags are parsed
- A flag that takes a value uses the next token unless you write `--name=value`. If the next token starts with `--`, the flag is treated as having no value.
- These flags never take a value: `--json`, `--yes`, `--public`, `--private`, `--no-wait`, `--quiet`, `--signed`, `--help`, `--version`, `--stdin`.
- Single-letter shortcuts: `-b` is `--bucket`, `-f` is `--folder`, `-y` is `--yes`, `-q` is `--quiet`, `-h` is `--help`, and `-v` is `--version`. Note that `-h` is help while `--h` is height, and `-q` is quiet while `--q` is quality.
- `--` ends option parsing; everything after it is a positional argument, which is how you upload a file whose name starts with `--`.
#### Credentials
The CLI looks for an API key in this order and uses the first it finds:
1. `--api-key`
2. The `STEADYLINK_API_KEY` environment variable
3. The key saved by `steadylink login`
The API origin follows the same pattern (`--api-url`, then `STEADYLINK_API_URL`, then the saved value), and so does the delivery origin (`--cdn-url`, then `STEADYLINK_CDN_URL`, then the saved value). Without a key, any command that calls the API exits with code 3.
The key needs `assets:read` for `ls`, `link`, `versions`, and `inspect`, and `assets:write` for `upload`, `replace`, `rm`, `rollback`, and signed links. `focal` calls a workspace-level route, so with an API key it needs `workspace:admin`.
##### Config file
`steadylink login` saves JSON with owner-only permissions (`0600`, in a `0700` directory) at the first of these that applies:
| Condition | Path |
| --- | --- |
| `STEADYLINK_CONFIG` is set | That path |
| Windows | `%APPDATA%\steadylink\config.json` |
| Otherwise | `$XDG_CONFIG_HOME/steadylink/config.json`, or `~/.config/steadylink/config.json` |
```json title="config.json"
{
"apiKey": "slk_live_...",
"apiUrl": "https://api.steadylink.io",
"cdnUrl": "https://media.example.com",
"bucket": "marketing"
}
```
`apiUrl`, `cdnUrl`, and `bucket` are only written when you pass them to `login`. Set `STEADYLINK_CONFIG` to a temporary path in CI so a job never reads or writes a developer's saved login.
#### Environment variables
| Variable | Used for |
| --- | --- |
| `STEADYLINK_API_KEY` | API key, when `--api-key` is not given. |
| `STEADYLINK_API_URL` | API origin, when `--api-url` is not given. |
| `STEADYLINK_CDN_URL` | Delivery origin for printed links, when `--cdn-url` is not given. |
| `STEADYLINK_BUCKET` | Default bucket for `upload`, when `--bucket` is not given. |
| `STEADYLINK_CONFIG` | Config file path. |
Empty values are treated as unset.
#### Commands
##### login
Verifies an API key by listing your buckets, then saves it to the config file.
```bash
steadylink login [--api-key ] [--bucket ] [--stdin]
```
**Options**
- `--api-key` (string): The key to save. Without it, `login` prompts for the key with hidden input when run in a terminal.
- `--stdin` (flag): Read the key from standard input. When stdin is not a terminal and no key is given, `login` reads it from stdin automatically.
- `--bucket, -b` (string): Save a default bucket (ID, slug, or name) for `upload`.
- `--api-url` (string): Save an API origin.
- `--cdn-url` (string): Save a delivery origin for printed links.
Reading from stdin keeps the key out of your shell history:
```bash
printf '%s' "$STEADYLINK_API_KEY" | steadylink login --stdin
# Logged in with slk_…9f3a. 4 buckets visible.
# Saved to /home/ana/.config/steadylink/config.json
```
The printed key is masked to its first and last four characters. An invalid key exits with code 3 and nothing is saved.
##### logout
Removes the saved key. Other saved settings are kept; if none remain, the config file is deleted.
```bash
steadylink logout
```
##### upload
Uploads files and folders and prints one stable link per file.
```bash
steadylink upload [--bucket ] [--folder ] [--public | --private]
```
**Options**
- `--bucket, -b` (string): Destination bucket ID, slug, or name. Falls back to `STEADYLINK_BUCKET`, then the bucket saved by `login`. Required one way or another.
- `--folder, -f` (string, default bucket root): Folder inside the bucket. `--path` is an accepted alias.
- `--public` (flag): Make each uploaded file public. Cannot be combined with `--private`.
- `--private` (flag): Make each uploaded file private. Private files need a [signed link](#link).
- `--no-wait` (flag): Return as soon as the bytes are sent, before SteadyLink finishes processing. Links may be missing from the output.
- `--via` (api | presigned, default presigned): `api` streams bytes through the SteadyLink API instead of directly to object storage, for networks that block the storage host.
- `--concurrency` (number, default 4): Parallel transfers.
How files are collected:
- A file argument is uploaded into `--folder` under its own name.
- A folder argument is walked recursively, and the folder structure below it is kept. `steadylink upload ./dist --folder site` turns `dist/css/app.css` into `site/css/app.css`. The folder's own name is not included.
- Symbolic links are skipped. Files within a folder are uploaded in sorted path order.
- Files are streamed from disk, so large files are never loaded into memory. Content types are guessed from extensions.
- Files go up in batches of 100, and each one is finalized with an idempotency key so a retried completion cannot create a duplicate revision.
- Uploading to a key that already exists adds a new revision to that file; its link stays the same.
```bash
steadylink upload ./public/media --bucket marketing --folder campaign --public
# Uploading 3 files (4.2 MB)
# campaign/hero.webp https://cdn.steadylink.io/a/3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d
# campaign/logo.svg https://cdn.steadylink.io/a/9d4e2b7a-1c3f-4a8e-b6d0-7f2c5e1a9b38
# campaign/press/kit.pdf https://cdn.steadylink.io/a/c81f0a6d-5e2b-4d7c-9a3e-1b6f8d2c4e07
# 3 uploaded.
```
Progress is drawn as a bar on stderr in a terminal, or printed at 25 percent steps when stderr is not a terminal. If some files fail, the others are still uploaded, each failure is printed as `FAILED` with its reason, and the command exits with code 4.
##### replace
Publishes a new revision of an existing file. The asset ID and link stay the same, so every page, email, and QR code that uses the link serves the new file.
```bash
steadylink replace
```
**Options**
- `--bucket, -b` (string): Treat the first argument as a key inside this bucket.
- `--key` (string): Older form: `steadylink replace --key marketing:docs/price-list.pdf ./price-list.pdf`. The target comes from `--key` and the file is the only positional argument.
The second argument must be exactly one file, not a folder.
```bash
steadylink replace marketing:docs/price-list.pdf ./price-list-2026.pdf
# Replaced docs/price-list.pdf with price-list-2026.pdf. Now on revision 4.
# https://cdn.steadylink.io/a/3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d
```
If the new file is caught by the malware scan before commit, the command exits with code 1 and the previous revision keeps serving. If the background scan finds malware after the replacement went current, the file is left with no current revision and its link returns `404` until you `rollback` to a clean revision. See [The scan window after a replacement](https://steadylink.io/docs/concepts/stable-links#the-scan-window-after-a-replacement). To undo a replacement, use [`rollback`](#rollback).
##### ls
Lists your buckets, or the folders and files directly inside one bucket folder.
```bash
steadylink ls [bucket[:folder/]]
```
Without an argument (and without `--bucket`), `ls` lists buckets: ID, slug, `public` or `private`, and name. With a bucket, it lists one level: folders first, then files with size, asset ID, key, and `[private]` for private files. Files still processing show `(processing)` in place of an asset ID. `--folder` sets the folder when you pass a bucket without a colon.
```bash
steadylink ls marketing:campaign/
# campaign/press/
# 812 KB 3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d campaign/hero.webp
# 14 KB 9d4e2b7a-1c3f-4a8e-b6d0-7f2c5e1a9b38 campaign/logo.svg [private]
```
`ls` does not read `STEADYLINK_BUCKET` or the saved default bucket; with no argument it always lists buckets.
##### link
Prints a delivery link. With transform flags the link is resized or converted on delivery; with `--signed` it is an expiring, revocable link for a private file.
```bash
steadylink link [--w 800] [--h 600] [--fm webp] [--q 80] [--fit cover] [--revision 3]
steadylink link --signed [--ttl 3600] [--name label]
```
**Options**
- `--w` (number): Width in pixels. `--width` also works.
- `--h` (number): Height in pixels. `--height` also works.
- `--fm` (webp | jpg | png): Output format. `--format` also works. Other values exit with code 2.
- `--q` (number): Quality, clamped to 30 through 95. `--quality` also works.
- `--fit` (cover | contain | inside | outside): Resize mode. Other values exit with code 2.
- `--revision` (number): Pin the link to one revision. Without it, the link follows the current revision.
- `--signed` (flag): Create a signed link. With `--revision`, the grant itself is pinned to that revision.
- `--ttl` (number, default 300): Signed link lifetime in seconds, clamped by the API to 60 through 2,592,000 (30 days).
- `--name` (string): Label for the signed link, shown when listing grants.
`` can be any of the [addressing forms](#addressing-files-and-buckets). For `bucket:key`, the CLI looks up the asset ID first and fails if the file is still processing.
```bash
steadylink link 3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d --w 1200 --fm webp
# https://cdn.steadylink.io/a/3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d?w=1200&fm=webp
steadylink link 3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d --signed --ttl 86400 --name "Acme review"
# https://cdn.steadylink.io/a/3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d?token=...
# Expires 2026-10-09T14:03:11.482913Z. Revoke with grant 6b0e3d5a-....
```
The token in a signed link is shown only once. Revoke it early through the dashboard or [`revokeSignedLink`](https://steadylink.io/docs/reference/sdk#revokesignedlink).
##### rm
Deletes a file and every one of its revisions and cached transforms. Its link stops working for everyone. This cannot be undone.
```bash
steadylink rm [--yes]
```
**Options**
- `--yes, -y` (flag): Skip the confirmation prompt.
- `--bucket, -b` (string): Treat the argument as a key inside this bucket.
Without `--yes`, the CLI asks `Delete {key} and every revision? Its link will stop working. [y/N]`. Anything other than `y` or `yes` cancels and exits 0. When there is no terminal to ask (a script or CI job), `rm` without `--yes` refuses and exits with code 2.
##### versions
Lists a file's retained revisions, newest first. `*` marks the current one.
```bash
steadylink versions 3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d
# * v4 2026-10-08 09:12:44 812 KB image/webp
# v3 2026-09-30 16:40:02 798 KB image/webp "Approved"
# v2 2026-09-12 11:05:19 1.1 MB image/png
```
##### rollback
Makes an older revision current again. The link stays the same and serves that revision.
```bash
steadylink rollback
```
`version` must be a whole number of 1 or more.
```bash
steadylink rollback 3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d 3
# Revision 3 is current again. https://cdn.steadylink.io/a/3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d
```
##### inspect
Prints file details as JSON: bucket, key, size, content type, ETag, last modified, scan status, and the link. If the ID is a bucket rather than a file, it prints the bucket instead. Output is always JSON, with or without `--json`.
```bash
steadylink inspect 3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d
```
##### focal
Sets the focal point that `fit=cover` crops keep in frame.
```bash
steadylink focal --x 0.42 --y 0.31
```
`--x` and `--y` are both required and run from 0 (left or top) to 1 (right or bottom).
#### JSON output
With `--json`, stdout carries exactly one JSON document and progress is suppressed, so you can pipe it into `jq`:
```bash
steadylink upload ./dist/*.zip --bucket releases --json | jq -r '.[].url'
```
| Command | Output |
| --- | --- |
| `login` | `{ "loggedIn": true, "configPath": "...", "key": "slk_…9f3a", "buckets": 4 }` |
| `logout` | `{ "loggedOut": true }` |
| `upload` | An array of `{ "file", "status", "assetId", "url", "visibility"?, "error"? }`, one per file. |
| `replace` | `{ "replaced", "version", "bucketId", "key", "assetId", "url" }` |
| `ls` (no bucket) | An array of buckets: `{ "id", "name", "slug", "isPrivate", ... }` |
| `ls` (bucket) | `{ "prefix", "folders", "items", "revision" }`, where each item adds a `url` (or `null` while processing). |
| `link` | `{ "assetId", "url" }`, or with `--signed`: `{ "assetId", "url", "expiresAt", "grantId" }` |
| `rm` | `{ "deleted": true, "bucketId", "key" }`, or `{ "deleted": false, "key" }` when cancelled. |
| `versions` | An array of revisions: `{ "id", "versionNumber", "isCurrent", "byteSize", "mime", "label", "note", "createdAt", ... }` |
| `rollback` | `{ "promoted", "versionNumber", "revisionId", "url" }` |
| `focal` | `{ "x", "y" }` |
```json title="upload --json"
[
{
"file": "releases/app-1.4.0.zip",
"status": "ready",
"assetId": "c81f0a6d-5e2b-4d7c-9a3e-1b6f8d2c4e07",
"url": "https://cdn.steadylink.io/a/c81f0a6d-5e2b-4d7c-9a3e-1b6f8d2c4e07",
"visibility": "public"
},
{
"file": "releases/app-1.4.0.dmg",
"status": "failed",
"assetId": null,
"url": null,
"error": { "code": "upload_failed", "message": "Storage rejected the upload (HTTP 403)" }
}
]
```
When an upload partly fails with `--json`, the full array is still printed and the exit code is 4. Any other error is printed to stdout as:
```json title="Error"
{
"error": {
"message": "API key expired or revoked (HTTP 401). Check the API key and its scopes.",
"status": 401,
"requestId": "7d3c1f2e-9a4b-4c6d-8e1f-0a2b3c4d5e6f"
}
}
```
`status` and `requestId` appear for API errors only, and `code` only when the CLI can read a machine-readable code from the response. For an error such as a missing scope, where the API returns a structured detail, the message falls back to `SteadyLink request failed with HTTP 403`; run the same call with `curl` to see the full body. Without `--json`, errors go to stderr as `steadylink: {message}`, with ` [request {id}]` appended when the API returned a request ID.
#### Exit codes
| Code | Meaning |
| --- | --- |
| `0` | Success, including a cancelled `rm` prompt. |
| `1` | API, network, or file error, such as a missing local file, a 404, or a rejected scan. |
| `2` | Invalid usage: unknown command, missing argument, bad flag value, `--public` with `--private`, or `rm` without `--yes` where there is no terminal. |
| `3` | No API key found, or the API rejected the key with 401 or 403 (including a missing scope). |
| `4` | Some files in an `upload` failed; the others succeeded. |
#### Examples
```bash
# Deploy a build folder and keep the links
steadylink upload ./dist/assets --bucket web --folder "releases/$GITHUB_SHA" --json > links.json
# Swap a PDF that is already linked from emails and QR codes
steadylink replace marketing:docs/price-list.pdf ./price-list-2026.pdf
# Share a private file for one day
steadylink link marketing:contracts/acme.pdf --signed --ttl 86400 --name "Acme review"
# Undo a bad replacement
steadylink versions 3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d
steadylink rollback 3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d 3
# Delete from a script
steadylink rm marketing:campaign/old.webp --yes
```
#### Next steps
- [GitHub Action](https://steadylink.io/docs/reference/github-action): Run uploads and replacements from a workflow with outputs and a job summary.
- [Upload and replace](https://steadylink.io/docs/guides/upload-and-replace): What happens to a file between upload and its first delivery.
- [API keys](https://steadylink.io/docs/security/api-keys): Create a key with the right scopes and keep it safe.
### GitHub Action
Source: https://steadylink.io/docs/reference/github-action
`SteadyLink-io/upload-action` uploads build output to SteadyLink from a GitHub Actions workflow, or replaces an existing file so its published link serves the new build. Use it for release downloads, documentation PDFs, and media that other sites link to. This page lists every input and output and explains exactly how files are matched and when the step fails.
The action is a composite action that runs [`@steadylink/cli`](https://steadylink.io/docs/reference/cli) through `npx` on the runner's Node.js (18 or later). It works on Linux, macOS, and Windows runners.
#### Upload files
```yaml title=".github/workflows/release.yml"
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: SteadyLink-io/upload-action@v1
id: steadylink
with:
api-key: ${{ secrets.STEADYLINK_API_KEY }}
bucket: releases
folder: ${{ github.ref_name }}
files: |
dist/*.zip
dist/*.dmg
visibility: public
- run: echo "Download: ${{ steps.steadylink.outputs.link }}"
```
Store the key as a repository or environment secret. It needs the `assets:read` and `assets:write` scopes. Keys are created in **Dashboard > Developers**.
#### Replace a file in place
Set `replace` to publish the build as a new revision of an existing file. The asset ID stays the same, so every existing link, embed, and QR code serves the new version. This is the way to keep a permanent "latest" download link.
```yaml
- uses: SteadyLink-io/upload-action@v1
with:
api-key: ${{ secrets.STEADYLINK_API_KEY }}
replace: releases:downloads/app-latest.dmg # or an asset ID, or https://cdn.steadylink.io/a/{id}
files: build/App.dmg
```
In replace mode, `files` must match exactly one file. `bucket`, `folder`, and `visibility` are ignored.
#### Inputs
**Inputs**
- `api-key` (string): SteadyLink API key with `assets:write`. Passed to the CLI as `STEADYLINK_API_KEY`.
- `files` (string): Files, folders, or globs, one per line or separated by spaces. See [How files are matched](#how-files-are-matched).
- `bucket` (string): Destination bucket ID, slug, or name. Required for uploads, ignored for replacements.
- `folder` (string): Folder inside the bucket, for example `releases/v1.2`. Empty means the bucket root.
- `visibility` (string): `public` or `private` (case-insensitive). Empty inherits the bucket default. Any other value fails the step.
- `replace` (string): Replace this existing file instead of uploading: an asset ID, a delivery link, or `bucket:path/to/file`.
- `api-url` (string, default https://api.steadylink.io): API origin.
- `cdn-url` (string): Delivery origin used for the returned links, for example a custom delivery domain. Empty uses `https://cdn.steadylink.io`.
- `cli-version` (string, default 0.2.0): Version of `@steadylink/cli` to run.
- `working-directory` (string, default .): Directory that relative paths and globs resolve against.
#### Outputs
**Outputs**
- `link` (string): Link of the first file that has one. Empty when no file produced a link.
- `links` (JSON string): Array of every link, in upload order. Files without a link are left out. Read one with `fromJSON(steps.steadylink.outputs.links)[1]`.
- `results` (JSON string): Array with one object per file, described below.
Each entry in `results`:
| Field | Upload | Replace |
| --- | --- | --- |
| `file` | Key in the bucket, for example `v1.4.0/app.zip` | Key of the replaced file |
| `url` | Stable link, or `null` | Stable link, or `null` |
| `assetId` | Asset ID, or `null` | Asset ID, or `null` |
| `status` | `ready`, `scanning`, `committing`, `blocked`, `failed`, or `cancelled` | `replaced` |
| `error` | Error message, only for failed files | Not present |
| `version` | Not present | New revision number |
```json title="results"
[
{ "file": "v1.4.0/app.zip", "url": "https://cdn.steadylink.io/a/c81f0a6d-5e2b-4d7c-9a3e-1b6f8d2c4e07", "assetId": "c81f0a6d-5e2b-4d7c-9a3e-1b6f8d2c4e07", "status": "ready" },
{ "file": "v1.4.0/app.dmg", "url": "https://cdn.steadylink.io/a/9d4e2b7a-1c3f-4a8e-b6d0-7f2c5e1a9b38", "assetId": "9d4e2b7a-1c3f-4a8e-b6d0-7f2c5e1a9b38", "status": "ready" }
]
```
Use the outputs in later steps, for example to post the link in a release note:
```yaml
- run: gh release edit "$TAG" --notes "Download: ${{ steps.steadylink.outputs.link }}"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
```
#### How files are matched
The `files` input is split on new lines and spaces, so a path that contains a space cannot be given directly; match it with a glob such as `dist/My?App.dmg` instead. Each entry is then handled one of two ways:
- **A plain path** (no `*` or `?`) is passed to the CLI as is. It must exist, or the step fails with `No such file or directory`. A folder is uploaded recursively and keeps its structure below the folder: `dist` puts `dist/css/app.css` at `{folder}/css/app.css`.
- **A glob** is matched against every file under `working-directory`, skipping `.git` and `node_modules` folders. `*` matches within one path segment, `**` matches across segments (`docs/**/*.pdf` includes `docs/a.pdf` and `docs/x/y/b.pdf`), and `?` matches one character. A glob that matches nothing fails the step with `No files match {pattern}`.
**Warning: Globs upload files flat.**
Every file a glob matches is uploaded directly into `folder`, under its own name, without the directories it came from. `docs/**/*.pdf` puts `docs/guide/intro.pdf` at `{folder}/intro.pdf`. If two matched files share a name, the second becomes a new revision of the first. To keep directory structure, pass the folder itself (`docs`) instead of a glob.
Duplicate matches across entries are uploaded once.
#### Job summary
When at least one file was processed, the action appends a table to the job summary:
```text
### SteadyLink
| File | Status | Link |
| --- | --- | --- |
| `v1.4.0/app.zip` | ready | https://cdn.steadylink.io/a/c81f0a6d-... |
| `v1.4.0/app.dmg` | failed | Storage rejected the upload (HTTP 403) |
```
Replacements show the new revision, for example `replaced (v4)`. The step log also prints the CLI command it ran (without the key) and one `file -> link` line per file.
#### Failure behavior
The step fails, and the workflow stops unless you set `continue-on-error`, when:
- `api-key` is empty, `files` matches nothing, a plain path does not exist, `bucket` is missing for an upload, or `visibility` is not `public` or `private`.
- `replace` is set and `files` matched zero or several files.
- The CLI reports an error, such as an invalid key, a missing scope, an unknown bucket, or a file that failed the malware scan during a replacement. The error message is shown as a workflow annotation titled **SteadyLink**.
- Any file in an upload failed. The other files are still uploaded, and `link`, `links`, `results`, and the job summary are still written, so a later step with `if: always()` can read them.
Uploads and replacements are not retried by the action. Re-running the job uploads the same keys again, which adds new revisions to those files rather than creating duplicates.
#### Security
- Pass the key only through `secrets`. It reaches the CLI as an environment variable and is never printed.
- The CLI's config file is written under `RUNNER_TEMP`, so the action never reads or changes a saved login on a self-hosted runner.
- Pin `cli-version` (the default is `0.2.0`) and the action's major version so a release cannot change behavior unexpectedly.
- Give the key only `assets:read` and `assets:write`, and consider a separate key per repository so you can revoke one without affecting others.
#### Next steps
- [CLI](https://steadylink.io/docs/reference/cli): The commands the action runs, for local use or other CI systems.
- [Upload and replace](https://steadylink.io/docs/guides/upload-and-replace): How uploads, scanning, and replacements behave.
- [API keys](https://steadylink.io/docs/security/api-keys): Create a scoped key for your workflow.
### S3-compatible API
Source: https://steadylink.io/docs/reference/s3
SteadyLink buckets speak the Amazon S3 protocol, so tools built for S3 can sync, back up, and browse them. Use this page to create an app key, configure your tool, and understand where SteadyLink behaves differently from Amazon S3.
Files written through S3 follow the same rules as dashboard uploads: plan upload limits, storage quota, malware scanning, workspace encryption, and audit logs. A `PUT` to a key that already exists adds a new revision behind the same stable link, so you can sync a folder with rclone and every published `https://cdn.steadylink.io/a/{asset_id}` link keeps serving the latest file.
#### Connection details
| Setting | Value |
| --- | --- |
| Endpoint | `https://api.steadylink.io/s3` (path style) |
| Dedicated host | `https://s3.steadylink.io`, for tools that only accept a bare host (restic, Cyberduck, many NAS apps) |
| Region | `auto`. Any region name is accepted; it only has to match what the client signs with. |
| Addressing | Path style: `https://api.steadylink.io/s3/{bucket}/{key}`. Virtual-host style (`{bucket}.s3.steadylink.io`) works only on the dedicated host. |
| Signature | AWS Signature Version 4, in the `Authorization` header or as presigned query parameters. Version 2 is not supported. |
| Bucket name | The bucket's slug, or its ID when it has no S3-compatible slug. `rclone lsd` or `aws s3 ls` shows the names. |
| Key | The file path inside the bucket, for example `photos/2024/beach.jpg`. |
#### App keys
S3 tools authenticate with an app key: an access key ID that starts with `SL` and a 40-character secret. App keys are separate from workspace API keys.
- An app key belongs to one user in one workspace and acts with that user's role. Removing the user from the workspace, or lowering their role, takes effect on the next request.
- Each user can have up to 20 active app keys per workspace.
- The secret is shown only in the response that creates the key. Store it right away.
- App keys bypass workspace SSO enforcement, in the same way workspace API keys do. Revoke a person's keys when they leave.
##### Create an app key
**POST /api/s3-keys**
App keys are currently created through the API, with a signed-in user's session token (`Authorization: Bearer`). Workspace API keys cannot create them, because an app key acts as a person; a request with `X-API-Key` returns `403`. A dashboard screen for app keys is coming. Send `X-Workspace-Id` if you belong to more than one workspace.
**Body**
- `name` (string): A label, 1 to 200 characters, for example `NAS backup`.
- `readOnly` (boolean, default false): The key can list and download, but every write returns `AccessDenied`. Viewers can create read-only keys; a read-write key needs a role that can write files.
- `bucketId` (uuid): Limit the key to one bucket. `ListBuckets` then returns only that bucket, and the key cannot create or delete buckets. An unknown bucket returns `404`.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/s3-keys \
-H "Authorization: Bearer $STEADYLINK_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "NAS backup", "readOnly": false, "bucketId": "8b1f6c2e-4a7d-4f0e-b3c9-2d5e7a9f1c04"}'
```
```json title="Response" status="201 Created"
{
"id": "5c2e9a1f-3b7d-4e8a-9f0c-1d2b3a4c5e6f",
"name": "NAS backup",
"accessKeyId": "SLQ4M7ZK2XB5N3TJ6WRA",
"readOnly": false,
"bucketId": "8b1f6c2e-4a7d-4f0e-b3c9-2d5e7a9f1c04",
"createdAt": "2026-10-08T09:30:12.418211",
"lastUsedAt": null,
"revokedAt": null,
"secretAccessKey": "q7Xb2LwP9sKd4RmT1vYc8NfJ3hZa6GeU5oWi0BtE",
"endpoint": "https://api.steadylink.io/s3",
"region": "auto",
"forcePathStyle": true
}
```
Creating a twenty-first active key returns `409` with the code `s3_key_limit_reached`.
##### List your app keys
**GET /api/s3-keys**
Returns your active keys in the current workspace, newest first, as `{ "items": [...], "endpoint", "region", "forcePathStyle" }`. Each item has the same fields as the create response without `secretAccessKey`. `lastUsedAt` is updated at most every five minutes.
##### Revoke an app key
**DELETE /api/s3-keys/{key_id}**
Returns `{ "revoked": true }`. You can revoke your own keys; workspace admins can revoke anyone's, for offboarding. Revoking an already revoked key also returns `{ "revoked": true }`. Creation and revocation are recorded in the audit log as `workspace.s3_key_create` and `workspace.s3_key_revoke`.
#### Set up your tool
The examples use the access key ID `SLXXXXXXXXXXXXXXXXXX`, the secret `YOUR_SECRET`, and a bucket named `my-bucket`.
**rclone**
To install rclone and create this remote in one step, see [Connect rclone in one command](https://steadylink.io/docs/guides/rclone).
Create the remote from the command line:
```bash
rclone config create steadylink s3 \
provider=Other \
access_key_id=SLXXXXXXXXXXXXXXXXXX \
secret_access_key=YOUR_SECRET \
endpoint=https://api.steadylink.io/s3 \
region=auto \
force_path_style=true
```
Or add it to `rclone.conf`:
```ini title="rclone.conf"
[steadylink]
type = s3
provider = Other
access_key_id = SLXXXXXXXXXXXXXXXXXX
secret_access_key = YOUR_SECRET
endpoint = https://api.steadylink.io/s3
region = auto
force_path_style = true
```
Then:
```bash
rclone lsd steadylink: # list buckets
rclone copy ~/Pictures steadylink:my-bucket/pictures
rclone sync ~/Documents steadylink:my-bucket/documents
rclone check ~/Documents steadylink:my-bucket/documents
```
rclone stores modification times in `X-Amz-Meta-Mtime` and, for multipart uploads, the MD5 in `X-Amz-Meta-Md5chksum`. Both are kept with each revision, so `sync` and `check` work without re-uploading. Changing only a modification time (`rclone touch`, or a `sync` where only the time differs) updates the current revision's metadata and does not create a new revision.
Keep the chunk size at 5 MiB or more; rclone's default is fine.
**restic**
restic, and other tools built on minio-go, only accept a bare host, so use the dedicated host:
```bash
export AWS_ACCESS_KEY_ID=SLXXXXXXXXXXXXXXXXXX
export AWS_SECRET_ACCESS_KEY=YOUR_SECRET
export RESTIC_REPOSITORY=s3:https://s3.steadylink.io/my-bucket/restic
restic init -o s3.region=auto
restic backup ~/Documents -o s3.region=auto
```
Each restic pack file becomes a file in the bucket and counts against your storage quota like any other upload. Keep the repository in its own folder (or its own bucket with a bucket-limited key) so it stays out of the way of files you publish.
**Cyberduck**
1. Choose **Open Connection** and pick **Amazon S3**.
2. Set **Server** to `s3.steadylink.io` and **Port** to `443`.
3. Enter the access key ID and the secret.
If `s3.steadylink.io` is not reachable from your network, use the **S3 (HTTPS)** connection profile with server `api.steadylink.io` and path `/s3` instead.
Folders you create in Cyberduck are stored as folder markers, the same way the dashboard stores empty folders.
**AWS CLI**
```bash
aws configure set default.s3.addressing_style path
aws --endpoint-url https://api.steadylink.io/s3 --region auto s3 ls s3://my-bucket/
aws --endpoint-url https://api.steadylink.io/s3 --region auto s3 cp ./report.pdf s3://my-bucket/reports/report.pdf
```
Use a named profile (`--profile steadylink`) with the app key as `aws_access_key_id` and `aws_secret_access_key` so it does not replace your AWS credentials.
**boto3**
```python
import boto3
from botocore.config import Config
s3 = boto3.client(
"s3",
endpoint_url="https://api.steadylink.io/s3",
region_name="auto",
aws_access_key_id="SLXXXXXXXXXXXXXXXXXX",
aws_secret_access_key="YOUR_SECRET",
config=Config(s3={"addressing_style": "path"}),
)
s3.upload_file("report.pdf", "my-bucket", "reports/report.pdf")
for item in s3.list_objects_v2(Bucket="my-bucket", Prefix="reports/").get("Contents", []):
print(item["Key"], item["Size"])
```
NAS backup apps usually ask for a server address rather than a URL: enter `s3.steadylink.io`, choose a custom S3-compatible provider, and keep HTTPS on.
#### Supported operations
| Operation | Notes |
| --- | --- |
| ListBuckets, HeadBucket, GetBucketLocation | The location is empty, so clients fall back to their configured region. |
| CreateBucket | Creates a bucket whose slug is the S3 name. Names are global and must be 3 to 63 lowercase letters, digits, dots, or hyphens. A taken name returns `BucketAlreadyExists`. |
| DeleteBucket | Only when the bucket is empty (`BucketNotEmpty` otherwise). |
| ListObjects (v1) and ListObjectsV2 | `prefix`, `delimiter` (CommonPrefixes for folders), `max-keys`, `marker`, `start-after`, continuation tokens, and `encoding-type=url`. |
| HeadObject, GetObject | A single `Range`, `If-Match`, `If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since`, `response-*` overrides, and presigned URLs. |
| PutObject | Streaming, `Content-MD5` and `x-amz-content-sha256` checks, aws-chunked uploads (signed chunks and checksum trailers), and `x-amz-meta-*` metadata up to 2 KB in total. |
| CopyObject | Server-side, with `x-amz-metadata-directive` `COPY` or `REPLACE`. Clients use it for renames. |
| DeleteObject, DeleteObjects | Deleting a key removes the file and all of its revisions, as in the dashboard. Its stable link stops working. |
| Multipart upload | CreateMultipartUpload, UploadPart, ListParts, CompleteMultipartUpload, AbortMultipartUpload, and ListMultipartUploads. |
| GetObjectAcl, PutObjectAcl, GetBucketAcl | Accepted for client compatibility, but they change nothing. Access is controlled by app keys only. |
#### ETags
The ETag is the MD5 of the whole file, including for multipart uploads, where Amazon S3 would return an `{md5-of-parts}-{N}` value instead. Tools that compare ETags to local MD5 sums therefore work for every file.
Revisions created before the S3 API existed have an ETag ending in `-1`, which tells clients that it is not an MD5. Tools then fall back to size and modification time, or to the `X-Amz-Meta-Md5chksum` value rclone stores.
#### Errors
Errors use the standard S3 XML body, with codes such as `NoSuchKey`, `NoSuchBucket`, `AccessDenied`, `SignatureDoesNotMatch`, `InvalidRange`, and `EntityTooLarge`. Error responses carry an `x-amz-request-id` header and the same ID in `RequestId`.
```xml
QuotaExceeded... /s3/my-bucket/video.mp4 7d3c1f2e-...
```
Two cases are specific to SteadyLink:
| Code | HTTP | Meaning |
| --- | --- | --- |
| `QuotaExceeded` | 403 | The workspace's storage or delivery allowance is used up. It is a 403 so clients fail fast instead of retrying. Free space or upgrade, then run the job again. |
| `AccessDenied` with a message starting `Upload blocked` | 403 | Malware was found in the file (`Upload blocked: malware detected`), or the bucket's file-type or size policy rejected it (`Upload blocked by bucket policy`). Retrying the same file will fail again. |
A read-only key that tries to write, or a bucket-limited key that touches another bucket, also gets `AccessDenied`.
#### Limits and rate limits
- The largest object is the plan's maximum upload size, and storage counts against the plan quota. See [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits).
- Multipart parts are limited to 5 GiB each. Every part except the last must be at least 5 MiB and all parts except the last must be the same size; rclone and the AWS tools already do this.
- Downloads count against the workspace delivery allowance, like `/a` links.
- Each app key can make 1,200 reads and 600 writes per minute. Requests over the limit get `SlowDown` (HTTP 503), which S3 clients retry with backoff. Failed signature checks are limited per client IP address.
#### Known limitations
- **No versioning API.** `versionId` and ListObjectVersions are not supported. Revisions exist and are visible in the dashboard and the [revisions API](https://steadylink.io/docs/reference/api/revisions), but S3 always reads and writes the current one.
- **No bucket policies, CORS, lifecycle rules, tagging, object lock, or SSE-C.** ACL calls are accepted and ignored.
- **No UploadPartCopy or `GetObject?partNumber`.**
- **Large files in memory.** Committing a file reads it into memory on the API server, as dashboard commits do. In workspaces with encryption, a `Range` request decrypts the whole object first, so reading parts of very large files is slow.
- **Malware scanning timing.** With synchronous scanning an infected upload is rejected. With asynchronous scanning the upload succeeds and the object later becomes unreadable (`AccessDenied`) if the scan finds malware. Objects still waiting for a scan can be read with an app key.
- **Key rules.** Keys cannot start with `/` or contain `//`, `.` or `..` segments, or control characters. Folder paths and file names are limited to 512 characters each (`KeyTooLongError`).
- **Content checks.** A file whose bytes clearly contradict its declared `Content-Type` is rejected, as in the dashboard.
- **Abandoned multipart uploads** expire after 7 days.
- **Empty folders.** Folders created in the dashboard appear in listings only while they have a folder marker. rclone does not create empty folders on S3 remotes.
#### Next steps
- [Buckets and folders](https://steadylink.io/docs/guides/buckets-and-folders): How buckets, slugs, and folders map to what S3 tools see.
- [Revisions](https://steadylink.io/docs/concepts/revisions): What happens when a sync overwrites a file that already has a link.
- [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits): Storage, delivery, and upload size by plan.
### Webhooks
Source: https://steadylink.io/docs/reference/webhooks
Webhooks let your systems react when something changes in a SteadyLink workspace: purge a cache when a new revision goes live, alert a channel when a scan finds malware, or close a ticket when a migration finishes. This page covers registering endpoints, every event and its payload, how to verify the signature, and how retries work.
A workspace can send events to two kinds of destination:
- **A generic HTTPS endpoint** that you run. It receives the JSON event with an HMAC signature you can verify.
- **A Discord, Slack, or Microsoft Teams channel.** SteadyLink formats each event as a native message for that app. These requests are not signed, because the channel URL itself is the credential.
#### Register an endpoint
In the dashboard, open **Dashboard > Developers**, choose the **Webhooks** tab, and select **New webhook**. Pick the destination type, paste the URL, and tick the events. For a generic endpoint, the signing secret is shown once, right after you create it.
Through the API, managing webhooks needs a workspace admin. With an API key, listing needs the `workspace:read` scope, and creating, testing, and deleting need `workspace:admin`.
##### Create a webhook
**POST /api/platform/webhooks** (scope: `workspace:admin`)
**Body**
- `url` (string): The destination, 12 to 2,048 characters. Must be a public `https://` URL with no username or password in it. `localhost`, `.local` hosts, and private or reserved IP addresses are rejected, and the host is resolved again before every delivery so it cannot later point at a private network.
- `events` (string[]): 1 to 10 event types from [Events](#events). Duplicates are removed.
- `destinationType` (string, default generic): `generic`, `discord`, `slack`, or `teams`. See [Destination types](#destination-types) for the URLs each one accepts.
```bash title="curl"
curl -X POST https://api.steadylink.io/api/platform/webhooks \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.example.com/hooks/steadylink",
"destinationType": "generic",
"events": ["asset.revision.published", "asset.scan.completed"]
}'
```
```ts title="JavaScript"
// createWebhook() always registers a generic endpoint.
const webhook = await steadylink.createWebhook("https://app.example.com/hooks/steadylink", [
"asset.revision.published",
"asset.scan.completed",
]);
console.log(webhook.secret); // store it now
```
```python title="Python"
webhook = client.create_webhook(
"https://app.example.com/hooks/steadylink",
["asset.revision.published", "asset.scan.completed"],
)
print(webhook["secret"]) # store it now
```
```json title="Response" status="201 Created"
{
"id": "0f6b2c8e-3d1a-4b7e-9c5f-2a8d4e6b1c93",
"url": "https://app.example.com/hooks/steadylink",
"destinationType": "generic",
"events": ["asset.revision.published", "asset.scan.completed"],
"enabled": true,
"createdAt": "2026-10-08T09:41:27.115032",
"secret": "whsec_kP3x9QmZ2vR7tLw1bN8cY4sD6fH0jG5aE2uI9oKqT3M"
}
```
`secret` is returned only here, and only for generic endpoints. SteadyLink stores it encrypted and cannot show it again; if you lose it, delete the webhook and create a new one.
| Status | Code | When |
| --- | --- | --- |
| `409` | `feature_limit_reached` | The workspace already has as many endpoints as its plan allows. |
| `422` | `invalid_webhook_url` | The URL is not public HTTPS, or does not match the destination type. |
| `422` | `unsupported_webhook_events` | An event type is not in the list below. The response names the unsupported events. |
##### List webhooks
**GET /api/platform/webhooks** (scope: `workspace:read`)
Returns every endpoint and the event types you can subscribe to:
```json title="Response" status="200 OK"
{
"availableEvents": ["asset.delivery.threshold", "asset.revision.published", "asset.scan.completed", "migration.completed"],
"items": [
{
"id": "0f6b2c8e-3d1a-4b7e-9c5f-2a8d4e6b1c93",
"url": "https://app.example.com/hooks/steadylink",
"destinationType": "generic",
"events": ["asset.revision.published", "asset.scan.completed"],
"enabled": true,
"createdAt": "2026-10-08T09:41:27.115032"
},
{
"id": "7a2e4c9b-1f3d-4e8a-b6c0-5d9f2a7e3b14",
"url": "slack://hooks.slack.com/••••••",
"destinationType": "slack",
"events": ["asset.scan.completed"],
"enabled": true,
"createdAt": "2026-10-08T09:44:02.903871"
}
]
}
```
Channel URLs contain a credential, so they are stored encrypted and listed only as `{type}://{host}/••••••`.
##### Send a test event
**POST /api/platform/webhooks/{webhook_id}/test** (scope: `workspace:admin`)
Sends one event to the endpoint immediately and waits for the answer, so you can check your receiver end to end. The test event uses the endpoint's first subscribed event type (alphabetically) and this `data`:
```json
{ "test": true, "message": "Your SteadyLink webhook is connected." }
```
A generic test is signed like any other delivery. The response is `{ "delivered": true, "responseStatus": 200, "deliveryId": "..." }` when your endpoint returns a 2xx. Anything else, including a timeout, returns `502` with the code `webhook_delivery_failed` and the reason in `message`. Tests count toward the monthly delivery allowance and are not retried.
##### List recent deliveries
**GET /api/platform/webhooks/{webhook_id}/deliveries** (scope: `workspace:read`)
Returns the 50 most recent deliveries for the endpoint, newest first:
```json title="Response" status="200 OK"
{
"items": [
{
"id": "b3d9f1a2-6c4e-4f8b-9a1d-7e2c5b8f0a36",
"event": "asset.revision.published",
"status": "delivered",
"attempts": 2,
"responseStatus": 200,
"error": null,
"createdAt": "2026-10-08T10:02:15.448190"
}
]
}
```
`status` is `queued`, `retrying`, `delivered`, `failed` (retries exhausted), or `cancelled` (the endpoint was disabled before delivery). A test in progress shows `processing`. `error` holds the last failure, such as `Endpoint returned HTTP 500`.
##### Delete a webhook
**DELETE /api/platform/webhooks/{webhook_id}** (scope: `workspace:admin`)
Returns `204 No Content`. There is no update route: to change the URL or events, create the new webhook first, then delete the old one, so no events are missed in between.
#### Destination types
| `destinationType` | Accepted URLs | What is sent |
| --- | --- | --- |
| `generic` | Any public HTTPS URL | The JSON event, signed. |
| `discord` | `https://discord.com/...` or `https://discordapp.com/...` with `/webhooks/` in the path | A Discord embed with a title, description, up to four fields, and a link to the file. |
| `slack` | `https://hooks.slack.com/services/...` or `https://hooks.slack-gov.com/services/...` | Slack Block Kit: header, description, fields, and an **Open file** button. |
| `teams` | A Teams Workflow URL on `*.webhook.office.com`, `*.logic.azure.com`, `*.environment.api.powerplatform.com`, or `*.powerplatform.com` | An Adaptive Card with the same content. |
Channel messages are written for people (for example "New revision is live: hero.webp is now serving revision v4.") and do not include the full event. Use a generic endpoint when a program needs the data.
#### Events
| Event | Fires when |
| --- | --- |
| `asset.revision.published` | A new revision becomes current: a file is uploaded (new or to an existing key, through the API, dashboard, S3, or a migration), a file is replaced, or an older revision is promoted or rolled back. |
| `asset.scan.completed` | The malware scan of a revision finishes (`clean`, `infected`, or `error`), or a synchronous scan blocks an upload. |
| `asset.delivery.threshold` | Delivery bandwidth for the billing period crosses 50, 80, or 100 percent of the plan allowance. Each threshold fires once per period. |
| `migration.completed` | All files in a [bulk migration](https://steadylink.io/docs/reference/api/platform) reach a final state. |
| `request.submitted` | Someone sends files through a [file request](https://steadylink.io/docs/guides/asset-requests). |
| `proofing.submitted` | A client sends their picks from a [proofing collection](https://steadylink.io/docs/guides/client-proofing). If you let them change their picks, sending again fires a new event. |
#### Payload
Every event delivered to a generic endpoint has the same envelope:
**Envelope**
- `id` (string): Unique event ID. Retries of the same event reuse it. Treat it as opaque: it may start with `evt_` or encode its source, as in `scan:{revisionId}:clean`.
- `type` (string): The event type.
- `createdAt` (string): When the event was queued, ISO 8601 in UTC, for example `2026-10-08T10:02:15.448190Z`.
- `data` (object): Event-specific fields, described below.
The body is compact JSON with keys sorted alphabetically.
##### asset.revision.published
`data` describes the action that published the revision:
**data**
- `action` (string): `asset.object_version_create` (an upload), `bucket.object_replace` (a replacement), `asset.commit` (a direct commit), or `asset.revision_promote` (a promotion or rollback).
- `assetId` (string): The file's asset ID, except for `bucket.object_replace`, where it is the bucket ID and the file's asset ID is in `metadata.objectAssetId`.
- `success` (boolean): Always `true` for published revisions.
- `metadata` (object): Depends on `action`. Always includes `version`, the revision now current. Uploads add `bucketId`, `key`, and `scan_status`; replacements add `key` and `objectAssetId`; direct commits add `mime` and `byte_size`; promotions add `previousRevisionId`.
```json title="asset.revision.published"
{
"createdAt": "2026-10-08T10:02:15.448190Z",
"data": {
"action": "bucket.object_replace",
"assetId": "8b1f6c2e-4a7d-4f0e-b3c9-2d5e7a9f1c04",
"metadata": {
"key": "campaign/hero.webp",
"objectAssetId": "3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d",
"version": 4
},
"success": true
},
"id": "audit:7d3c1f2e-9a4b-4c6d-8e1f-0a2b3c4d5e6f:bucket.object_replace:8b1f6c2e-4a7d-4f0e-b3c9-2d5e7a9f1c04",
"type": "asset.revision.published"
}
```
To get the file's asset ID reliably, use `data.metadata.objectAssetId` when it is present and `data.assetId` otherwise.
##### asset.scan.completed
When a background scan finishes:
```json title="asset.scan.completed"
{
"createdAt": "2026-10-08T10:02:31.902114Z",
"data": {
"assetId": "3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d",
"revision": 4,
"revisionId": "e4a1c7b2-9d3f-4a6e-8b0c-1f5d2e9a7c48",
"status": "clean"
},
"id": "scan:e4a1c7b2-9d3f-4a6e-8b0c-1f5d2e9a7c48:clean",
"type": "asset.scan.completed"
}
```
`status` is `clean`, `infected`, or `error`. An `infected` revision is never served. An `error` means the scan could not complete; the dashboard shows the revision's state.
When a synchronous scan blocks a file before it is stored, `data` instead has the audit shape: `action` is `asset.scan_infected`, `assetId` is the bucket ID, `success` is `true`, and `metadata` holds the scanner's signature in `sig` plus the temporary key and, for uploads, the `path` and `filename`. No revision is created in that case.
##### asset.delivery.threshold
```json title="asset.delivery.threshold"
{
"createdAt": "2026-10-08T11:15:00.031877Z",
"data": {
"limit": 53687091200,
"periodEnd": "2026-11-01T00:00:00",
"periodStart": "2026-10-01T00:00:00",
"resource": "delivery_bytes",
"threshold": 80,
"usage": 42950000000
},
"id": "usage:...:delivery_bytes:2026-10-01T00:00:00:80",
"type": "asset.delivery.threshold"
}
```
`usage` and `limit` are bytes. `threshold` is `50`, `80`, or `100`. If one burst of traffic crosses several thresholds at once, only the highest one is sent.
##### migration.completed
```json title="migration.completed"
{
"createdAt": "2026-10-08T12:40:09.554201Z",
"data": {
"completedFiles": 98,
"failedFiles": 2,
"migrationId": "1c7e3a9d-5b2f-4e8c-a6d0-9f3b2e1a7c54",
"status": "partial",
"uploadBatchId": "6d2b8f4a-0e3c-4a9d-b7f1-3c5e8a2d9b06"
},
"id": "migration:1c7e3a9d-5b2f-4e8c-a6d0-9f3b2e1a7c54:partial",
"type": "migration.completed"
}
```
`status` is `complete` when every file succeeded and `partial` when some failed.
##### request.submitted
```json title="request.submitted"
{
"createdAt": "2026-10-21T09:15:03.120554Z",
"data": {
"files": [
{
"contentType": "image/svg+xml",
"filename": "logo-dark.svg",
"id": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"size": 18233
}
],
"requestId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"requestName": "Brand assets for the spring launch",
"respondentEmail": "priya@northwind.example",
"respondentName": "Priya Raman",
"reviewUrl": "https://steadylink.io/dashboard/requests/review?file=f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"submissionId": "2c7d1e9a-4b3f-4a10-9d2e-5f6a7b8c9d0e",
"submittedAt": "2026-10-21T09:15:03.101992"
},
"id": "request:2c7d1e9a-4b3f-4a10-9d2e-5f6a7b8c9d0e",
"type": "request.submitted"
}
```
`respondentName` and `respondentEmail` are `null` when the request doesn't ask for them. The files wait in review until someone approves them, so `files[].id` is the submitted file, not a published asset. Use `reviewUrl` to open the review screen.
##### proofing.submitted
```json title="proofing.submitted"
{
"createdAt": "2026-10-22T18:40:00.512870Z",
"data": {
"collectionId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"collectionName": "Hart & Osei wedding",
"count": 2,
"email": "amara@example.com",
"files": [
{ "assetId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "filename": "DSC_0412.jpg", "itemId": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f" },
{ "assetId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "filename": "DSC_0587.jpg", "itemId": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a" }
],
"name": "Amara Osei",
"note": "Could the first-dance photo be a little warmer?",
"reviewUrl": "https://steadylink.io/dashboard/sharing?proofing=1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"selectionId": "7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918",
"submittedAt": "2026-10-22T18:40:00.498113"
},
"id": "proofing:7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918:1792850400",
"type": "proofing.submitted"
}
```
`files` is in the order the client picked them. `note` is `null` when they didn't leave one. The `id` includes the send time, so a client who changes their picks and sends again produces a new event instead of a duplicate.
#### Verify the signature
Requests to a generic endpoint carry these headers:
| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `SteadyLink-Webhooks/1.0` |
| `X-SteadyLink-Id` | The event ID, the same as `id` in the body. |
| `X-SteadyLink-Timestamp` | Unix time in seconds when this attempt was sent. |
| `X-SteadyLink-Signature` | `v1=` followed by the lowercase hex HMAC-SHA256 of `{timestamp}.{raw body}`, keyed with your webhook secret. |
To verify a request:
1. Read the body as raw bytes, before any JSON parsing. Re-serializing parsed JSON changes the bytes and breaks the signature.
2. Reject the request if the timestamp is more than five minutes from your current time. This stops an intercepted request from being replayed later.
3. Compute HMAC-SHA256 over the timestamp, a period, and the raw body, using the full secret string (including its `whsec_` prefix) as the key.
4. Prefix the hex digest with `v1=` and compare it to `X-SteadyLink-Signature` with a constant-time comparison.
Each retry is signed again with a new timestamp, so a retried delivery never fails the age check.
```ts title="JavaScript"
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const secret = process.env.STEADYLINK_WEBHOOK_SECRET!; // whsec_...
export function verifySteadyLink(rawBody: Buffer, timestamp: string | undefined, signature: string | undefined): boolean {
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const digest = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
const expected = Buffer.from(`v1=${digest}`);
const received = Buffer.from(signature);
return expected.length === received.length && timingSafeEqual(expected, received);
}
const app = express();
app.post("/hooks/steadylink", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySteadyLink(req.body, req.get("X-SteadyLink-Timestamp"), req.get("X-SteadyLink-Signature"))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// Store the event (deduplicated by event.id) and process it asynchronously.
res.sendStatus(204);
});
```
```python title="Python"
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["STEADYLINK_WEBHOOK_SECRET"] # whsec_...
app = Flask(__name__)
def verify(secret: str, timestamp: str | None, body: bytes, signature: str | None) -> bool:
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
digest = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"v1={digest}", signature)
@app.post("/hooks/steadylink")
def steadylink_webhook():
body = request.get_data() # raw bytes
if not verify(SECRET, request.headers.get("X-SteadyLink-Timestamp"), body, request.headers.get("X-SteadyLink-Signature")):
abort(401)
event = json.loads(body)
# Store the event (deduplicated by event["id"]) and process it asynchronously.
return "", 204
```
In a Next.js route handler, read the body with `await request.arrayBuffer()` and pass `Buffer.from(...)` to `verifySteadyLink`.
#### Respond, retries, and deduplication
Return any `2xx` status as soon as you have stored the event. SteadyLink waits up to 10 seconds for a response; do slow work after responding. Answer at the registered URL itself rather than redirecting, because a redirected `POST` is not re-sent with its body.
A delivery that times out, cannot connect, or gets any non-2xx status is retried with exponential backoff: roughly 2, 4, 8, and 16 seconds after the first, second, third, and fourth failures. After five failed attempts the delivery is marked `failed` and no more attempts are made. You can see every attempt in [delivery history](#list-recent-deliveries).
Design your receiver for at-least-once delivery:
- **Deduplicate by event ID.** A retry reuses the same `id` (also in `X-SteadyLink-Id`). If your endpoint processed an event but the response was lost, the retry arrives with the same ID; skip IDs you have already handled. SteadyLink itself never queues the same event ID twice for one endpoint.
- **Do not rely on order.** Retries and parallel work mean `asset.scan.completed` can arrive before the `asset.revision.published` for the same revision. Use `createdAt` and the revision number to order events.
- **Re-read when it matters.** Events carry identifiers, not full state. Fetch the file or revision from the API before acting on something like visibility or the current revision.
#### Limits
Endpoints and deliveries are counted per workspace:
| Plan | Webhook endpoints | Deliveries per month |
| --- | --- | --- |
| Free | 1 | 100 |
| Personal | 3 | 1,000 |
| Pro | 15 | 10,000 |
| Business | 100 | 100,000 |
| Enterprise | 1,000 | 1,000,000 |
Each event sent to each endpoint is one delivery, including test events; retries of a delivery do not count again. When the monthly allowance is used up, new events are not queued for the rest of the billing period, and they are not sent later. Test sends return `409 feature_limit_reached` instead.
#### Webhooks and notification settings
Workspace webhooks on this page are for systems: they deliver the event types above to the endpoints an admin registers, with a signature for generic receivers.
Notification settings are for people. Each member chooses which notifications they get, such as file replacements, request submissions, and failed jobs, and whether each one arrives in the app, by email, or through the **Webhook** channel. See [Notifications](https://steadylink.io/docs/guides/notifications). Notification payloads contain a dashboard link and resource identifiers, never file contents or signed download URLs.
#### Next steps
- [Platform API](https://steadylink.io/docs/reference/api/platform): Migrations, delivery domains, delivery policy, and other workspace controls.
- [Revisions](https://steadylink.io/docs/concepts/revisions): What a published revision is and how rollbacks work.
- [Errors](https://steadylink.io/docs/reference/errors): Error bodies and status codes for the routes on this page.
### Errors
Source: https://steadylink.io/docs/reference/errors
Every SteadyLink API error has the same JSON shape, a standard HTTP status, and a request ID. Use this page to decide whether a failed request should be fixed, retried, or reported, and to find the exact meaning of an error code your integration received.
The S3-compatible API uses S3's XML errors instead; see [S3 errors](https://steadylink.io/docs/reference/s3#errors).
#### Error body
```json title="Response" status="403 Forbidden"
{
"code": 403,
"message": {
"code": "insufficient_scope",
"required": "assets:write"
},
"requestId": "7d3c1f2e-9a4b-4c6d-8e1f-0a2b3c4d5e6f"
}
```
**Fields**
- `code` (integer): The HTTP status code, repeated in the body.
- `message` (string | object): A sentence such as `"Upload session expired"`, or an object with a machine-readable `code` and extra fields. Branch on `message.code` when `message` is an object, and fall back to the HTTP status when it is a string.
- `requestId` (string): The request ID, also returned in the `X-Request-Id` header.
Structured messages always have a `code`, and often a human-readable `message` inside them:
```json title="Response" status="429 Too Many Requests"
{
"code": 429,
"message": {
"code": "api_write_limit_exceeded",
"message": "Rate limit exceeded",
"retryAfter": 12
},
"requestId": "0b9e4d2a-6f1c-4a3e-8d7b-2c5f9e1a4b60"
}
```
When the request body or query string does not match the route's schema (a missing field, a wrong type, a value out of range), the response is a `400` with the plain message `"Validation error"` and no field details. Check the request against the route's reference page.
**Note.**
The SDKs read errors differently. In `@steadylink/sdk` 0.2.0 the full body is on `error.detail` and `error.code` is not filled in for API errors; in the Python client 0.1.0, `error.code` holds the numeric status. Both SDK pages show a small helper that reads `message.code`.
#### Status codes
| Status | Meaning | What to do |
| --- | --- | --- |
| `400` | The request is malformed: schema validation failed, both `Authorization` and `X-API-Key` were sent, or the operation is not allowed in this state (for example deleting the current revision without `force=true`, or a malware-blocked replacement). | Fix the request. Do not retry it unchanged. |
| `401` | No credential, or the credential is invalid, expired, or revoked (`Unauthorized`, `Invalid API key`, `API key expired or revoked`). | Check the key or refresh the session. |
| `403` | The caller is authenticated but not allowed: missing scope, a workspace role without the permission, an inactive API entitlement, a plan feature that is not included, or delivery of a file blocked as infected. | Check the code in `message`. Retrying will not help. |
| `404` | The resource does not exist in the active workspace, or the caller cannot see it. | Check the ID and the workspace. |
| `409` | The resource is in a conflicting state: it already exists, is already processing, an idempotency key does not match, or a plan count limit was reached (`feature_limit_reached`, `seat_limit_reached`). | Read the resource again, then decide. |
| `410` | The thing you referenced expired: an upload session, a conversion, an invitation, a replacement request, a widget submission, or a collection link. | Create a new one. |
| `413` | Too large: the file exceeds the plan's upload size, or storing it would exceed the workspace storage allowance. | Upload a smaller file, free space, or upgrade. |
| `415` | Unsupported media: the declared type contradicts the file's bytes (`MIME mismatch`), the type is not accepted, or a transform was requested for a non-raster file. | Fix the content type or the request. |
| `422` | The request is well-formed but a value is not acceptable, such as an invalid webhook URL, delivery domain, or country code. | Fix the value named in `message`. |
| `423` | Delivery only: the requested revision has not finished its malware scan. | Retry after a few seconds. |
| `429` | A rate limit, usage limit, or concurrency limit was reached. | Wait for `Retry-After`, then retry. |
| `451` | Delivery only: the workspace's country policy blocks the requester's region. | Nothing to retry. |
| `500` | Unexpected server error. The body is `"Internal Server Error"`. | Retry reads and idempotent writes with backoff; report it with the request ID if it persists. |
| `502` | An upstream service failed: storage could not save the bytes, an SSO provider could not be reached, or a webhook test endpoint did not answer with a 2xx. | Retry with backoff. |
| `503` | A feature is not configured on the server, or the service is temporarily unavailable. | Retry with backoff. |
| `504` | A gateway timed out before the API answered. | Retry reads and idempotent writes with backoff. |
#### Error codes
The `code` inside a structured `message`, grouped by what caused it.
##### Access and entitlement
| Code | Status | Meaning |
| --- | --- | --- |
| `insufficient_scope` | 403 | The API key lacks the scope this route needs. `required` names it, for example `assets:write`. |
| `insufficient_workspace_permission` | 403 | The signed-in user's role does not allow the action. Includes `permission` and `role`. |
| `subscription_inactive` | 403 | The workspace's paid plan is not active or its billing period has ended. Existing keys are kept and can still be listed and revoked in the dashboard, but they cannot authenticate until billing is active again. |
| `api_access_disabled` | 403 | API access was turned off for this workspace by an administrative override. |
| `paid_plan_required` | 403 | The workspace's plan does not include API access. |
| `business_plan_required` | 403 | The feature, such as SSO, needs a Business or Enterprise plan. |
| `workspace_required` | 400 | A user who belongs to several workspaces did not send `X-Workspace-Id`. |
Entitlement errors also carry `message` and `plan`. While a key is rejected for its entitlement, repeated attempts are themselves rate limited (see [Rate limits](#rate-limits)).
##### Limits and quotas
These share one shape:
```json title="Response" status="413 Payload Too Large"
{
"code": 413,
"message": {
"code": "upload_size_limit",
"resource": "upload_bytes",
"usage": 262144000,
"reserved": 0,
"requested": 262144000,
"limit": 104857600,
"resetAt": null,
"behavior": "hard_stop",
"upgrade": { "recommendedPlan": "personal", "url": "/dashboard/settings?section=plans" }
},
"requestId": "3a8f1c6e-2d4b-4e9a-b7c0-5f1e8d2a6c93"
}
```
| Code | Status | Meaning |
| --- | --- | --- |
| `upload_size_limit` | 413 | The file is larger than the plan allows for one upload. |
| `storage_quota_exceeded` | 413 | Storing the file would exceed the workspace storage allowance. Includes `usedBytes`, `requestedBytes`, and `limitBytes`. |
| `temporary_intake_storage_limit` | 413 | Pending request and widget uploads would exceed the remaining storage. |
| `usage_limit_exceeded` | 413 or 429 | A metered allowance for the billing period is used up (413 for storage, 429 for others). `resetAt` is when it renews. |
| `usage_throttled` | 429 | Usage over the allowance is being slowed rather than stopped. |
| `delivery_quota_exceeded` | 429 | The monthly delivery bandwidth allowance is used up. Sent with `Retry-After: 3600`. |
| `feature_limit_reached` | 409 | A count limit for the plan, such as webhook endpoints, collections, or webhook deliveries. `resource` names it. |
| `seat_limit_reached` | 409 | Inviting another member would exceed the plan's seats. |
| `api_key_limit_reached` | 409 | The workspace has as many active API keys as its plan allows. |
| `s3_key_limit_reached` | 409 | The user already has 20 active S3 app keys in this workspace. |
Plan limits are listed in [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits).
##### Rate-limit codes
| Code | Meaning |
| --- | --- |
| `api_burst_limit_exceeded` | Too many API-key requests in 10 seconds, across the workspace. |
| `api_read_limit_exceeded` | Too many API-key reads (`GET`, `HEAD`, `OPTIONS`) in one minute. |
| `api_write_limit_exceeded` | Too many API-key writes in one minute. |
| `api_monthly_quota_exceeded` | The workspace used its monthly API request allowance. |
| `api_expensive_operation_rate_exceeded` | Too many expensive writes in one minute. |
| `api_expensive_operation_limit_exceeded` | Too many expensive writes today. |
| `invalid_api_key_attempt_limit_exceeded` | More than 30 requests with an invalid or revoked key from one IP address in a minute. |
| `disabled_api_key_attempt_limit_exceeded`, `disabled_api_key_workspace_limit_exceeded` | Too many requests with a key whose workspace has no API entitlement (60 per minute per IP, 300 per minute per workspace). |
| `rate_limit_exceeded` | A per-user limit for signed-in sessions (see below). |
| `conversion_archive_rate_limit_exceeded` | More than 6 conversion archive requests in a minute. |
Every rate-limit body includes `retryAfter` in seconds, matching the `Retry-After` header.
#### Rate limits
API-key limits are pooled per workspace, not per key: creating more keys does not raise them. A request counts against the burst window, then the per-minute read or write window, then the monthly allowance, and, for expensive writes, the per-minute and per-day expensive limits.
| Plan | Reads per minute | Writes per minute | Burst per 10 s | Expensive per minute | Expensive per day | Requests per month |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Free | 120 | 30 | 30 | 10 | 250 | 50,000 |
| Personal | 300 | 75 | 75 | 25 | 2,500 | 250,000 |
| Pro | 1,200 | 300 | 300 | 120 | 25,000 | 2,000,000 |
| Business | 6,000 | 1,500 | 1,250 | 600 | 250,000 | 25,000,000 |
| Enterprise | 60,000 | 15,000 | 10,000 | 6,000 | 5,000,000 | 2,000,000,000 |
A write is **expensive** when its path contains `/upload`, `/commit`, `/replace`, `/purge`, `/signed-url`, or `/retry`, or when it is any write under `/api/conversions`. These are the operations that start scans, workers, or storage operations.
Signed-in sessions (the dashboard, or an `accessToken`) are not subject to the plan table. Their writes are limited per credential to 60 per minute, and delivery and download routes such as `/a/{asset_id}` allow 600 requests per minute per client when no API key is sent. Sign-in and other authentication routes allow 10 requests per minute per IP address. The S3-compatible API has its own [per-key limits](https://steadylink.io/docs/reference/s3#limits-and-rate-limits).
#### Response headers
| Header | When | Value |
| --- | --- | --- |
| `X-Request-Id` | Every response | Your own `X-Request-Id` if you sent one that is ASCII and at most 128 characters, otherwise a generated UUID. |
| `RateLimit` | API-key requests | `limit=300, remaining=297, reset=41`: the per-minute read or write window, with `reset` in seconds. |
| `RateLimit-Policy` | API-key requests | `"steadylink";q=300;w=60`: the window's quota and length in seconds. |
| `X-RateLimit-Limit` | API-key requests, and every 429 | Requests allowed in the window. |
| `X-RateLimit-Remaining` | API-key requests, and every 429 | Requests left in the window. `0` on a 429. |
| `X-RateLimit-Reset` | API-key requests, and every 429 | Unix time, in seconds, when the window resets. Note this is a timestamp, while `reset` in `RateLimit` is a number of seconds. |
| `Retry-After` | 429 responses, and some quota errors | Seconds to wait before retrying. |
On a successful request the rate-limit headers describe the per-minute read or write window. On a `429` they describe whichever window was exceeded. Browsers can read all of these headers from cross-origin requests.
Send your own `X-Request-Id` (for example your trace ID) to find a request in both your logs and SteadyLink's. When you report a problem, include the request ID, the route, the status, and the time. Never include the API key or a signed token.
#### Retry policy
| Situation | Retry? |
| --- | --- |
| Network error or timeout, safe method (`GET`, `HEAD`, `OPTIONS`) | Yes, with exponential backoff and jitter. |
| Network error or timeout on a write | Only if the write is idempotent (it carries an `Idempotency-Key`), or after reading the resource to check whether it happened. |
| `429` | Yes, after `Retry-After` seconds. Monthly and daily quota errors will keep failing until the window resets. |
| `423` on delivery | Yes, after a short wait for the scan to finish. |
| `500`, `502`, `503`, `504` | Yes for reads and idempotent writes, with backoff. Stop after a few attempts and report persistent `500`s. |
| `409` | Read the resource first; retry only if the state now allows it. |
| Other `4xx` | No. Fix the request. |
Start backoff around 250 ms and double it on each attempt, with random jitter so many clients do not retry at once. Both SDKs do this for you on `429`, `502`, `503`, and `504` (see [TypeScript SDK retries](https://steadylink.io/docs/reference/sdk#retries) and [Python SDK retries](https://steadylink.io/docs/reference/python-sdk#retries)).
##### Idempotency keys
Upload completion (`POST /api/upload-sessions/{id}/complete`) has an explicit idempotency contract. Send an `Idempotency-Key` header and reuse it on every retry of the same completion:
- The first completion stores the key. A retry with the same key, or a completion of a session that is already finished, returns the session without doing the work twice.
- A completion with a different key while the session already has one returns `409 Idempotency key mismatch`.
For any API-key request, an `Idempotency-Key` also identifies retries for metering, so a retried request with the same key counts once against the monthly management-operation allowance. It still counts against the per-minute rate limits. Other writes do not deduplicate on the key, so check the resource before retrying them.
#### Next steps
- [Authentication](https://steadylink.io/docs/reference/api/authentication): Credentials, scopes, and the headers every request needs.
- [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits): Storage, delivery, and upload allowances by plan.
- [TypeScript SDK](https://steadylink.io/docs/reference/sdk): Typed errors and built-in retries.
### Security
Source: https://steadylink.io/docs/security
Security in SteadyLink is part of the normal file workflow rather than a separate product. Every upload is checked before it can be delivered, every file is public or private by an explicit rule, and changes in a workspace are recorded. This page summarizes those protections for workspace admins and for anyone reviewing SteadyLink for their organization, and links to the detail.
#### Upload policies
A bucket can restrict what it accepts:
- **Maximum upload size**, in MB, on top of your plan's file size limit.
- **Allowed MIME type prefixes**, such as `image/` or `application/pdf`.
- **Allowed filename extensions**, such as `pdf` or `png`.
SteadyLink detects a file's real type from its bytes and compares the detected type, not the filename or the type the uploader claimed, with the bucket's rules. A file whose declared type contradicts its contents is rejected as a MIME mismatch. A file that breaks a policy is blocked: no revision is created, nothing is delivered, its temporary bytes are deleted, and the attempt is recorded. See [Upload lifecycle](https://steadylink.io/docs/concepts/uploads#what-is-checked-and-where).
#### Malware scanning and clean delivery
Every revision is scanned with ClamAV after it is stored. Until the result is in, delivery refuses to serve the revision and answers `423 Locked`. A revision found to contain malware is marked infected and is never delivered, cannot be restored, and is removed from the file's current position. Workspace settings show this as **Require a clean scan before delivery** under **Settings > Workspace > Defaults for new files**.
Scans are sized for the file: larger files get longer to scan, and a scan that cannot finish is retried rather than treated as clean.
#### Delivery hardening
Some file types can run code in a browser. SteadyLink limits that by default:
- HTML and SVG files are delivered as downloads (`Content-Disposition: attachment`) instead of being rendered on the delivery domain.
- Responses tell browsers not to guess a different content type than the one SteadyLink detected.
#### Public, private, and signed access
Each file is public or private, either explicitly or by inheriting its bucket's default. New workspaces start with **private** as the default for new files. A private file opens only with a valid signed link, which expires and can be revoked. See [Private links](https://steadylink.io/docs/security/private-links).
#### Encryption
Stored files are encrypted at rest by the storage platform. Workspaces can also turn on **Encrypt original files** in **Settings > Security**. With it on, SteadyLink encrypts each new revision with the workspace's own AES-256-GCM data key before storing it, and decrypts it inside the service for delivery and conversions. The setting applies to revisions uploaded after it is turned on; existing revisions are not re-encrypted. Turning it off affects only new uploads, and encrypted revisions stay readable.
#### Accounts and access
- **Passkeys** let each person sign in with Face ID, Touch ID, Windows Hello or a security key, with no password to steal. See [Passkeys](https://steadylink.io/docs/security/passkeys).
- **Two-step verification** protects each person's sign-in with an authenticator app and one-time recovery codes. See [Two-step verification](https://steadylink.io/docs/security/two-factor).
- **Roles** limit what each member can do. See [Workspaces and roles](https://steadylink.io/docs/concepts/workspaces-and-roles).
- **Scoped API keys** give integrations only the access they need, can expire, and can be revoked at once. See [API key safety](https://steadylink.io/docs/security/api-keys).
- **Single sign-on** lets Business and Enterprise workspaces require sign-in through their identity provider. See [Single sign-on](https://steadylink.io/docs/security/single-sign-on).
- **Signed-in browsers** are listed in **Settings > Security**, where you can sign out any session other than the current one.
#### Audit history
SteadyLink records security-relevant actions with the workspace, the acting user when there is one, and the time: uploads and replacements, policy blocks and malware detections, visibility changes, revision restores and deletions, signed-link creation, API key creation and revocation, member and role changes, and sign-in events such as two-step verification changes. Recent workspace activity is available from [`GET /api/analytics/activity`](https://steadylink.io/docs/reference/api/analytics).
#### Storage totals and workspace defaults
Storage counts every retained revision plus space reserved for uploads in progress, so deleting old revisions is how you reclaim space. Admins set the workspace defaults for new files (public or private, and clean-scan delivery) in **Settings > Workspace**. See [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits).
#### In this section
- [Two-step verification](https://steadylink.io/docs/security/two-factor): Turn on authenticator codes and keep recovery codes safe.
- [Passkeys](https://steadylink.io/docs/security/passkeys): Sign in with your device instead of a password.
- [Private links](https://steadylink.io/docs/security/private-links): Public, private, and inherited visibility, and expiring signed links.
- [API key safety](https://steadylink.io/docs/security/api-keys): Scopes, expiry, rotation, and revocation for API keys and S3 app keys.
- [Single sign-on](https://steadylink.io/docs/security/single-sign-on): Connect an OIDC identity provider and require it for your workspace.
#### Next steps
- Report a vulnerability or ask a security question at [support@steadylink.io](mailto:support@steadylink.io).
- Read [Upload lifecycle](https://steadylink.io/docs/concepts/uploads) for every check an upload passes through.
### Two-step verification
Source: https://steadylink.io/docs/security/two-factor
Two-step verification (also called two-factor authentication, or 2FA) asks for a six-digit code from an app on your phone every time you sign in, in addition to your password or your Google or GitHub sign-in. Someone who learns your password still cannot get into your account. This page shows how to turn it on, how sign-in changes, and how to recover if you lose your phone.
Two-step verification is set per person, not per workspace. Turning it on protects your account in every workspace you belong to.
#### Before you start
Install an authenticator app that supports time-based one-time passwords (TOTP), such as 1Password, Authy, Google Authenticator, or Microsoft Authenticator. Make sure your phone's clock is set automatically; codes depend on the time.
#### Turn it on
**Open the security settings**
In the dashboard, open **Settings > Security**. Under **Two-step verification**, choose **Turn on**.
**Add SteadyLink to your authenticator app**
The **Set up your authenticator app** dialog shows a QR code. Scan it with your app. If you cannot scan it, add an account in your app manually and enter the setup key shown under the QR code. The account appears in your app as **SteadyLink** with your email address.
**Confirm with a code**
Enter the six-digit code your app shows and choose **Turn on**. If the code does not match, check that your phone's time is correct and try the newest code.
**Save your recovery codes**
SteadyLink shows ten recovery codes. This is the only time they are shown. Choose **Copy** or **Download**, store them somewhere safe and separate from your phone, such as a password manager, then choose **I saved them**.
Until you confirm the first code, two-step verification is not on. If you close the dialog early, start again; a new setup key is generated each time.
#### Signing in with two-step verification
After your password, or after you return from Google or GitHub, SteadyLink asks for the code from your app:
1. Open your authenticator app and enter the current six-digit code for SteadyLink.
2. If you do not have your phone, choose **Use a recovery code** and enter one of your saved codes.
A few details that explain common errors:
- Codes change every 30 seconds. SteadyLink accepts the current code and the ones just before and after it, to allow for small clock differences.
- Each code works once. If you sign in twice within the same 30 seconds, wait for the next code.
- You have five minutes to enter the code after your password. After that, sign in again from the start.
- After ten wrong codes in ten minutes, further attempts on your account are refused for a while, even with the right code. Wait and try again.
Single sign-on is different: if your workspace uses [single sign-on](https://steadylink.io/docs/security/single-sign-on), signing in through your organization's identity provider does not ask for a SteadyLink code. Your identity provider is responsible for the second factor there.
#### Recovery codes
Recovery codes look like `k7mp-x3qa-9fhw`: twelve letters and digits in three groups. Letters that are easy to confuse, such as `i`, `l`, and `o`, are never used. When you type one, capitals, spaces, and dashes do not matter.
- Each recovery code works once. After you use one, it is gone.
- **Settings > Security** shows how many codes you have left, and warns you when you are running low.
- To get a fresh set, choose **New recovery codes** and confirm with your password, a current code from your app, or one of your unused recovery codes. Your old codes stop working immediately.
#### If you lose your phone
**Sign in with a recovery code**
At the code prompt, choose **Use a recovery code** and enter one of your saved codes.
**Move two-step verification to your new phone**
In **Settings > Security**, choose **Turn off** and confirm with your password or another unused recovery code. Then choose **Turn on** again and scan the new QR code with your new phone. The old phone's codes stop working as soon as you turn it off.
**Save the new recovery codes**
Turning it on again creates ten new recovery codes. Store them, and discard the old ones.
If you have lost both your phone and your recovery codes, you cannot sign in on your own. Contact [support@steadylink.io](mailto:support@steadylink.io) from the email address on the account.
**Warning.** Store recovery codes away from your phone. A password manager, or a printed copy kept somewhere secure, is fine. A screenshot on the same phone is lost with the phone.
#### Turn it off
In **Settings > Security**, choose **Turn off** and confirm in the same field with your password, a current code from your app, or an unused recovery code. If your account has no password because you sign in with Google or GitHub, use a code. Turning it off deletes your setup key and all recovery codes.
#### What it does not cover
- **API keys and S3 app keys** are not affected. They are separate credentials for integrations and never ask for a code. Protect them as described in [API key safety](https://steadylink.io/docs/security/api-keys).
- **Browsers already signed in** stay signed in. To end other sessions, use **Signed-in browsers** in **Settings > Security**.
- Workspaces cannot currently require every member to turn on two-step verification. To require a second factor for everyone, use [single sign-on](https://steadylink.io/docs/security/single-sign-on) with an identity provider that enforces it.
#### For developers
The dashboard uses these endpoints under `/api/auth/2fa`. All of them need a signed-in user session; API keys cannot call them.
| Route | Purpose |
| --- | --- |
| `GET /api/auth/2fa` | Status: `enabled`, `enabledAt`, `pending`, and `recoveryCodesRemaining`. |
| `POST /api/auth/2fa/enroll` | Starts setup. Returns `secret`, `otpauthUri` (render it as a QR code), `digits` (6), and `period` (30). |
| `POST /api/auth/2fa/confirm` | Body `{ "code": "123456" }`. Turns it on and returns `recoveryCodes` once. |
| `POST /api/auth/2fa/recovery-codes` | Replaces recovery codes. Body: `password`, `code`, or `recovery_code`. |
| `POST /api/auth/2fa/disable` | Turns it off. Body: `password`, `code`, or `recovery_code`. |
| `POST /api/auth/2fa/verify` | Completes a sign-in. Body: `challenge_token` with `code` or `recovery_code`. |
When an account has two-step verification on, a password sign-in returns `{ "mfa_required": true, "mfa_token": "...", "expires_in": 300 }` instead of a session. Send the `mfa_token` as `challenge_token` to `/verify` with the code. Errors use stable codes such as `invalid_code`, `mfa_challenge_expired`, and `mfa_attempt_limit_exceeded`.
#### Next steps
- [Single sign-on](https://steadylink.io/docs/security/single-sign-on): Require sign-in through your organization's identity provider.
- [API key safety](https://steadylink.io/docs/security/api-keys): Protect the credentials your integrations use.
### Passkeys
Source: https://steadylink.io/docs/security/passkeys
A passkey lets you sign in with the face, fingerprint or PIN you already use on your device. There's no password to type, reuse or leak, and a passkey only works on steadylink.io, so a look-alike phishing site can't use it.
Passkeys are saved by your password manager or device, such as Apple Passwords, Google Password Manager, Windows Hello, 1Password or Bitwarden. Most of them sync, so a passkey you add on your laptop also works on your phone.
#### Add a passkey
**Open the security settings**
In the dashboard, open **Settings > Security** and find **Passkeys**.
**Choose Add a passkey**
Your browser asks where to save it. Confirm with your fingerprint, face or PIN.
**Rename it if you like**
SteadyLink names the passkey after the place it was saved, such as **Apple Passwords**. Click the name to change it to something like **Work laptop**.
You can save up to 20 passkeys. Add one for each password manager or security key you use, so losing one device doesn't lock you out. SteadyLink emails you whenever a passkey is added or removed.
#### Sign in with a passkey
On the sign-in page, choose **Sign in with a passkey**, or click the email field and pick your passkey from the suggestions. You don't need to enter your email first.
Signing in with a passkey skips the authenticator code even if [two-step verification](https://steadylink.io/docs/security/two-factor) is on. Your device already checked two things: that you have it, and that it's you.
If you sign in with a password or with Google or GitHub and two-step verification is on, the second step also offers **Use a passkey** instead of typing a code.
Workspaces that require [single sign-on](https://steadylink.io/docs/security/single-sign-on) still require it. A passkey signs you in to SteadyLink, but those workspaces only open after you sign in through your organization's identity provider.
#### Remove a passkey
In **Settings > Security**, open the menu next to the passkey and choose **Remove passkey**. It stops working for SteadyLink right away. Your password manager may still list it, so you can delete it there too.
#### Troubleshooting
- **Your browser doesn't offer passkeys.** Use a recent version of Chrome, Safari, Edge or Firefox. On older systems, a password and two-step verification still work.
- **"This passkey isn't saved to a SteadyLink account."** The passkey was removed from your account, or belongs to another service. Sign in another way and add it again.
- **The prompt closes on its own.** Sign-in requests expire after five minutes. Try again.
### Private links
Source: https://steadylink.io/docs/security/private-links
Every file is either public, so anyone with its link can open it, or private, so it opens only with a valid signed link. This page is for anyone sharing files that should not be open to the world: contracts, unreleased media, customer documents. You will learn how visibility is decided, how to create and revoke signed links, and how to handle them safely.
Public files open for anyone with the link. Private files return 403 without a valid token. A signed link opens a private file until it expires or is revoked.
#### How visibility is decided
Each file has a visibility setting of `public`, `private`, or `inherit`:
| Setting | Result |
| --- | --- |
| `public` | Anyone with the stable link can open the file. |
| `private` | The file opens only with a valid signed link. |
| `inherit` | The file follows its bucket's default. |
New files take their visibility from the bucket's default for new files, and a bucket without its own default uses the workspace default. Admins set the workspace default under **Settings > Workspace > Defaults for new files > New files start as**. New workspaces default to **private**, so nothing becomes public unless someone chooses it.
To change one file in the dashboard, open it from **Files** and choose **Make public** or **Make private**. Over the API, use `POST /api/assets/{bucket_id}/objects/visibility` with the file's `key` and the new `visibility`; see [Files API](https://steadylink.io/docs/reference/api/files).
##### What a private file returns
Without a token, or with a token that is expired, revoked, or for another file, a private file's link returns `403 Forbidden`. The response does not reveal anything about the file.
Private responses are sent with `Cache-Control: private, no-store`, so browsers and CDNs never keep a shared copy. Every request comes back to SteadyLink and the token is checked each time.
**Warning: Making a public file private.** Switching a file from public to private stops new requests at once, but copies already downloaded or cached while it was public cannot be recalled. The stable link of a public file may be cached for five minutes, and a little longer while caches revalidate. If a file was public by mistake, treat its contents as exposed.
#### Signed links
A signed link is the file's normal stable link with a `token` added:
```text title="Signed link"
https://cdn.steadylink.io/a/3f2a9c1e-7b4d-4e8a-9c61-2d5f0a8b7e13?token=v2.3f2a9c1e-7b4d-4e8a-9c61-2d5f0a8b7e13.1791556330.b81e4c2a-5d3f-4a9b-8e17-0c6d2f9a4b53.5e0c9a7f...
```
Each signed link is backed by a grant that SteadyLink stores, so it can be listed and revoked. A grant has:
- **A lifetime** (`ttl`, in seconds). The default is 300 seconds (five minutes). Any value is clamped to between 60 seconds and 2,592,000 seconds (30 days).
- **An optional name**, up to 200 characters, for your own records, such as `Acme legal review`. Names are visible to workspace members only.
- **An optional revision.** Without one, the link follows the file's current revision, so a replacement is visible to the recipient. With one, the link opens only that revision.
##### Create a signed link
In the dashboard, open a private file and choose **Create private link**. Pick 15 minutes, 1 hour, 1 day, or 7 days, or enter a number of seconds, then **Create link** and **Copy private link**.
From code:
```bash title="curl"
curl -X POST "https://api.steadylink.io/api/assets/$ASSET_ID/signed-url?ttl=86400&name=Acme%20legal%20review" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
const signed = await steadylink.createSignedLink(assetId, {
ttlSeconds: 86_400,
name: "Acme legal review",
});
console.log(signed.url, signed.expiresAt);
```
```python title="Python"
grant = client.request(
"POST",
f"/api/assets/{asset_id}/signed-url?ttl=86400&name=Acme%20legal%20review",
)
url = f"https://cdn.steadylink.io/a/{asset_id}?token={grant['token']}"
```
```bash title="CLI"
steadylink link 3f2a9c1e-7b4d-4e8a-9c61-2d5f0a8b7e13 --signed --ttl 86400 --name "Acme legal review"
```
```json title="Response" status="201 Created"
{
"id": "b81e4c2a-5d3f-4a9b-8e17-0c6d2f9a4b53",
"token": "v2.3f2a9c1e-7b4d-4e8a-9c61-2d5f0a8b7e13.1791556330.b81e4c2a-5d3f-4a9b-8e17-0c6d2f9a4b53.5e0c9a7f...",
"name": "Acme legal review",
"revisionNumber": null,
"expiresAt": "2026-10-09T14:32:10.512000"
}
```
The token is returned only in this response. SteadyLink stores the grant, not a copy of the token, so keep the URL if you need to send it again. The token carries the file ID, the expiry time, and the grant ID in readable form, followed by a signature; changing any part of it makes it invalid.
Creating a signed link needs the `assets:write` scope.
##### Pin a signed link to one revision
Pass `revision` to make a link that opens exactly one revision, for example the version of a contract a customer is signing:
```bash title="Create a revision-pinned signed link"
curl -X POST "https://api.steadylink.io/api/assets/$ASSET_ID/signed-url?ttl=604800&revision=3" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
A revision-pinned token works only together with the matching `v` parameter. Build the link as `?v=3&token=...`. The same token without `v=3`, or with another revision, returns `403`.
##### List signed links
`GET /api/assets/{asset_id}/signed-urls` returns the 200 most recent grants for a file, newest first, with `id`, `name`, `revisionNumber`, `expiresAt`, `revokedAt`, and `createdAt`. Tokens are never included, so listing is safe to show in an admin screen. Listing needs `assets:read`.
##### Revoke a signed link
Revoking ends access immediately, even if the link has not expired:
```bash title="curl"
curl -X DELETE "https://api.steadylink.io/api/assets/$ASSET_ID/signed-urls/$GRANT_ID" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
```ts title="JavaScript"
await steadylink.revokeSignedLink(assetId, grantId);
```
```python title="Python"
client.request("DELETE", f"/api/assets/{asset_id}/signed-urls/{grant_id}")
```
Because private responses are never cached, the next request with a revoked token returns `403`. Bytes already downloaded stay with whoever downloaded them.
##### Signed links and public files
A signed link to a public file works, but the token is not checked: the file is open to everyone anyway. Signed links only restrict access while the file is private. If you create signed links for a public file and later make it private, those grants start to matter from that moment.
#### Handle signed links like credentials
Anyone who has a signed link can open the file until it expires or is revoked. They do not need an account, and the link can be forwarded.
- **Use the shortest lifetime that works.** Minutes for a download button, a day for a review, seven days for an external partner. Mint a new link when a recipient needs more time instead of starting long.
- **Create links when they are needed**, on your server, for the person asking. Do not generate a long-lived link once and paste it into source code, a public page, or a support macro.
- **Name every link** after who it is for, so a list of grants shows who still has access and what to revoke.
- **Revoke on change.** When a contract ends, a reviewer leaves, or a link is posted somewhere it should not be, revoke the grant.
- **Watch where links go.** Signed links appear in browser history, proxy logs, and the `Referer` of requests from pages that embed them.
To give a group access without handing out individual tokens, a [collection portal](https://steadylink.io/docs/guides/collections-and-portals) with a password or an expiry can be a better fit.
#### Next steps
- [Delivery API](https://steadylink.io/docs/reference/api/delivery): Signed-link and delivery routes in full.
- [Stable links](https://steadylink.io/docs/concepts/stable-links): How the same link follows the current revision.
- [Collections and portals](https://steadylink.io/docs/guides/collections-and-portals): Share a set of files through one branded page.
### API key safety
Source: https://steadylink.io/docs/security/api-keys
API keys and S3 app keys let software act on your workspace without a person signing in. They can do anything their permissions allow and never ask for a second factor, so they deserve the same care as a password. This page is for the developers and admins who create and look after those keys.
#### Two kinds of key
| | Workspace API key | S3 app key |
| --- | --- | --- |
| Looks like | `slk_` followed by a random string | An access key ID starting with `SL`, plus a 40-character secret |
| Belongs to | The workspace | The person who created it |
| Permissions | Its scopes | The creator's current role, optionally read-only and limited to one bucket |
| Created in | **Dashboard > Developers > New API key**, or the API, by an owner or admin | The API, by any member for themselves |
| Limit | Per plan: 1 active key on Free, 2 on Personal, 10 on Pro, 50 on Business | 20 active keys per person per workspace |
| Works for | The REST API, SDKs, CLI, GitHub Action | S3-compatible tools |
Both are shown in full only once, when they are created. SteadyLink stores only a hash of an API key, so a lost key cannot be shown again; create a new one.
#### Least privilege
Give each integration its own key with only the scopes it uses:
| Integration | Scopes |
| --- | --- |
| A build job that publishes release files | `assets:write`, plus `assets:read` if it looks files up |
| A website that renders file lists or creates signed links on the server | `assets:read`; add `assets:write` only if it creates signed links |
| A reporting job | `analytics:read` |
| Infrastructure code that manages webhooks, delivery policy, or SSO | `workspace:admin` |
A request outside a key's scopes returns `403` with the code `insufficient_scope` and the scope it needed:
```json title="Response" status="403 Forbidden"
{ "detail": { "code": "insufficient_scope", "required": "assets:write" } }
```
The dashboard starts a new key with **Read files** only; tick more permissions as needed. Keys created through the API without a `scopes` list get `assets:read` and `assets:write`, so always pass scopes explicitly.
Separate keys also make incidents smaller. If one integration's key leaks, you revoke that key and nothing else breaks.
#### Never put a key in a browser or an app
Anything shipped to a browser, a mobile app, or a desktop app can be read by its users. A key in client-side JavaScript, a public repository, or a mobile bundle should be treated as already leaked.
To let people upload from a browser, keep the key on your server and hand the browser only short-lived presigned upload URLs, or use the embeddable [upload widget](https://steadylink.io/docs/guides/upload-widget). See [Browser uploads](https://steadylink.io/docs/guides/browser-uploads).
Store keys in a secret manager or a protected environment variable such as `STEADYLINK_API_KEY`. In GitHub Actions, store them as encrypted repository or environment secrets.
#### Send one credential per request
Send a key in the `X-API-Key` header:
```bash title="Authenticated request"
curl https://api.steadylink.io/api/assets/ \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
A request that carries both `X-API-Key` and `Authorization: Bearer` is rejected with `400`, so a proxy or SDK that adds a user's session cannot silently change which identity a request runs as.
#### Expiry
Keys created through the API can expire. Pass `expiresInDays`, from 1 to 365:
```bash title="Create a key that expires in 90 days"
curl -X POST https://api.steadylink.io/api/workspaces/current/api-keys \
-H "Authorization: Bearer $STEADYLINK_ACCESS_TOKEN" \
-H "X-Workspace-Id: $STEADYLINK_WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{ "name": "Release pipeline", "scopes": ["assets:read", "assets:write"], "expiresInDays": 90 }'
```
Managing keys requires a signed-in owner or admin; an API key cannot create, list, or revoke keys, even with `workspace:admin`.
An expired key returns `401 API key expired or revoked`. Expiring keys force rotation to happen on a schedule rather than only after an incident. Keys created in the dashboard do not expire; revoke them when they are no longer needed.
#### Rotate without downtime
**Create the replacement key**
Create a new key with the same scopes and a name that says what it replaces, such as `Release pipeline 2026-10`.
**Deploy it**
Update the secret wherever the old key is used and redeploy.
**Confirm the old key is idle**
In **Dashboard > Developers**, the old key's **Used** time (`lastUsedAt` in the API) stops moving once nothing uses it. It is updated at most every five minutes, so wait a little longer than that.
**Revoke the old key**
Revoke it in **Dashboard > Developers**. Anything still using it fails with `401` straight away.
Rotation needs one free key slot. On Free, which allows a single active key, revoke the old key and create the new one in quick succession, and expect a short gap.
#### Revoke immediately when a key leaks
If a key appears in a log, a commit, a screenshot, or a chat:
1. Revoke it in **Dashboard > Developers** (open the key's actions and choose **Revoke key**), or with `DELETE /api/workspaces/current/api-keys/{key_id}`. Revocation takes effect on the next request.
2. Create a replacement with the same scopes and deploy it.
3. Review [recent activity](https://steadylink.io/docs/reference/api/analytics) for actions you do not recognize. Key creation and revocation are recorded there.
Removing a commit from Git history does not make a pushed key safe again. Revoke first.
#### Offboarding and single sign-on
API keys belong to the workspace, not to the person who created them. When someone leaves, their workspace API keys keep working until an admin revokes them. Review the key list when people change roles or leave.
Single sign-on enforcement applies to people signing in, not to keys. A workspace that requires SSO still accepts its API keys and S3 app keys. Keep that in mind when you turn enforcement on: existing keys remain a way in until you revoke them.
#### S3 app keys
S3 app keys follow the person who created them:
- SteadyLink checks the creator's membership and role on every S3 request. Remove the person from the workspace, or change them to viewer, and their keys lose that access immediately.
- Create keys as **read-only** for backup and sync tools that only need to read, and limit a key to **one bucket** when the tool works on one bucket.
- The secret is shown once. Each person can list and revoke only their own keys, with `GET /api/s3-keys` and `DELETE /api/s3-keys/{key_id}`.
See the [S3-compatible API](https://steadylink.io/docs/reference/s3) for setup.
#### Next steps
- [Authentication](https://steadylink.io/docs/reference/api/authentication): Headers, scopes, and authentication errors.
- [Workspaces and roles](https://steadylink.io/docs/concepts/workspaces-and-roles): How keys compare with user sessions and roles.
- [Browser uploads](https://steadylink.io/docs/guides/browser-uploads): Upload from a browser without exposing a key.
### Single sign-on
Source: https://steadylink.io/docs/security/single-sign-on
Single sign-on (SSO) lets people sign in to your SteadyLink workspace with your organization's identity provider, such as Okta, Microsoft Entra ID, or Google Workspace, instead of a separate password. Once it works, you can require it, so access follows your directory: when someone is removed there, they can no longer sign in here. This page is for the workspace owner or admin setting it up.
SSO is available on the Business and Enterprise plans. On other plans, saving a connection returns `403` with the code `business_plan_required`.
#### How it works
SteadyLink connects to one OpenID Connect (OIDC) provider per workspace using the authorization code flow with PKCE. When someone signs in:
1. They open the workspace's SSO login URL and are sent to your provider.
2. Your provider signs them in, including any multi-factor checks you require there.
3. SteadyLink checks the returned identity: it must be signed by your provider, meant for your client ID, include an email address, and mark that email as verified.
4. The email's domain must be one of your allowed domains, or sign-in is refused with `sso_domain_not_allowed`.
5. SteadyLink signs the person in. Someone signing in for the first time gets an account and joins the workspace as a **member**. Someone who already has a SteadyLink account with that email is matched to it.
SSO sign-ins do not ask for a SteadyLink two-step verification code. Your provider owns the second factor for those sign-ins.
#### Before you start
You need:
- A workspace on Business or Enterprise, and the owner or admin role.
- Permission to create an application (also called a client or app registration) in your identity provider.
- A provider that publishes OIDC discovery at `{issuer}/.well-known/openid-configuration` over HTTPS, and signs ID tokens with RS256 or ES256.
- Seats for the people who will join. A first-time SSO user cannot join a workspace that has no free seats.
#### Set it up
**Create an OIDC application in your provider**
Create a web application that uses the authorization code flow. Set its redirect URI (sometimes called a callback or reply URL) to exactly:
```text title="Redirect URI"
https://api.steadylink.io/api/platform/sso/callback
```
Allow the `openid`, `email`, and `profile` scopes, and make sure the ID token includes `email` and `email_verified`. Note the issuer URL, client ID, and client secret.
**Save the connection in SteadyLink**
Send the configuration with a signed-in owner or admin session, or an API key with `workspace:admin`. Leave `enabled` on and `enforce` off for now.
```bash title="Save the SSO connection"
curl -X PUT https://api.steadylink.io/api/platform/sso \
-H "X-API-Key: $STEADYLINK_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"issuer": "https://id.example.com",
"clientId": "steadylink-prod",
"clientSecret": "'"$OIDC_CLIENT_SECRET"'",
"emailDomains": ["example.com", "staff.example.com"],
"enabled": true,
"enforce": false
}'
```
The response includes `startPath`, the login path for your workspace, and `testedAt`, which is `null` until the first successful sign-in.
**Test a sign-in**
Open the login URL in a private browser window and sign in with an account from an allowed domain:
```text title="SSO login URL"
https://api.steadylink.io/api/platform/sso/9d1c7e40-2b6a-4f3e-8a15-6c0e2f9b4d71/start
```
The ID in the path is your workspace ID. A successful sign-in lands on the SteadyLink sign-in page, completes, and records the time as `testedAt`.
**Require SSO**
Once `testedAt` is set, save the same configuration again with `"enforce": true`. Share the login URL with your team, for example as a tile in your identity provider's app launcher.
##### Field rules
| Field | Rules |
| --- | --- |
| `issuer` | Must start with `https://`. No query string, fragment, or credentials. A trailing slash is removed. Must match the `issuer` in the provider's discovery document. |
| `clientId` | Up to 512 characters. |
| `clientSecret` | 8 to 4,096 characters. Required the first time; omit it later to keep the stored secret. It is stored encrypted and never returned. |
| `emailDomains` | 1 to 25 domains, such as `example.com`. Lower-cased; no `@`, and no leading or trailing dot. Subdomains must be listed separately. |
| `enabled` | Turns the connection on or off. Turning it off also turns enforcement off. |
| `enforce` | Requires SSO for the workspace. Allowed only after a successful test. |
`GET /api/platform/sso` returns the current configuration (without the secret), whether SSO is `available` on your plan, and `testedAt`.
#### Test before you enforce
SteadyLink will not let you require SSO until a sign-in through the connection has succeeded. Saving `"enforce": true` before that returns `409` with the code `sso_test_required`.
Changing the issuer, client ID, client secret, or allowed domains clears `testedAt`, because the old test no longer proves the new settings work. To change a connection that is already enforced, save the change with `"enforce": false`, test again, then turn enforcement back on.
#### What enforcement does
With enforcement on, members who signed in with a password, Google, or GitHub are refused when they open the workspace's files, with `403` and the code `sso_required`. The error includes `ssoUrl`, the login path to use instead. Signing in through SSO clears it.
Enforcement applies to people, not to keys. Workspace API keys and S3 app keys keep working. When you turn enforcement on, review the keys in the workspace and revoke any that belong to integrations you no longer trust. See [API key safety](https://steadylink.io/docs/security/api-keys#offboarding-and-single-sign-on).
Removing someone from your identity provider stops new SSO sign-ins, but it does not end sessions they already have in SteadyLink or remove them from the workspace. To cut access at once, also remove them under **Settings > Members**.
#### Troubleshooting
| Error | Cause |
| --- | --- |
| `invalid_sso_configuration` | The issuer is not HTTPS or is malformed, or an email domain is not valid. |
| `oidc_discovery_failed` | SteadyLink could not read `{issuer}/.well-known/openid-configuration`, its `issuer` did not match, or an endpoint was not HTTPS. |
| `sso_exchange_failed` | The provider rejected the code exchange, or the ID token was invalid: wrong audience or issuer, missing `email`, or `email_verified` not `true`. Check the redirect URI and client secret first. |
| `sso_domain_not_allowed` | The person's email domain is not in `emailDomains`. |
| `invalid_sso_state` | The sign-in took longer than 10 minutes or the link was reused. Start again from the login URL. |
| `seat_limit_reached` | The workspace has no free seat for a first-time user. Pending invitations count as used seats. |
#### Encrypted originals
Admins of workspaces with sensitive files often turn on SteadyLink's own encryption at the same time. In **Settings > Security**, **Encrypt original files** makes SteadyLink encrypt each new revision with the workspace's own key before storing it, in addition to the storage platform's encryption at rest. Delivery and conversions decrypt files inside the service, so links and integrations do not change. It applies to files uploaded after you turn it on. See the [Security overview](https://steadylink.io/docs/security#encryption).
#### Next steps
- [Platform API](https://steadylink.io/docs/reference/api/platform): SSO, delivery policy, webhooks, and encryption routes.
- [Workspaces and roles](https://steadylink.io/docs/concepts/workspaces-and-roles): What the member role can do, and how to change it.
- [Two-step verification](https://steadylink.io/docs/security/two-factor): Protect accounts that do not sign in through SSO.
### Changelog
Source: https://steadylink.io/docs/changelog
Changes to the SteadyLink dashboard, API, and developer tools, newest first. Package-level detail for the TypeScript SDK is in its [reference](https://steadylink.io/docs/reference/sdk).
#### October 2026
##### AI connectors, October 28
- **Use SteadyLink from Claude, ChatGPT, Cursor and other AI apps.** Add the connector at `https://mcp.steadylink.io/mcp`, sign in, pick a workspace, and choose whether the app can change files or only read them. Then ask the assistant to upload a file and give you a link, find a file, check your usage, or replace a file while keeping its link, with a way to undo. One-click buttons for Claude, Cursor, VS Code, Kiro, and Goose are in [Connect an AI app](https://steadylink.io/docs/guides/mcp). Works with workspaces on a paid plan.
- **Connected apps.** See every AI app that has access in **Developers > Connected apps**, and disconnect any of them.
- **Storage warnings.** The dashboard warns when a workspace is nearly out of storage. Uploads over S3 or rclone that hit the limit now say how much space is used and how to free some or upgrade.
- **Docs for AI assistants.** [llms-full.txt](https://steadylink.io/llms-full.txt) holds the full documentation in one file, and a [new guide](https://steadylink.io/docs/guides/ai-assistants) shows how to point an assistant at it.
##### Passkeys, client proofing, and a new look, October 27
- **Passkeys.** Sign in with Face ID, Touch ID, Windows Hello, or a security key, or use a passkey as your second step instead of a code. Add and manage them in **Settings > Security > Passkeys**. See [Passkeys](https://steadylink.io/docs/security/passkeys).
- **Client proofing.** Turn on proofing for a collection and clients can heart the photos they want, up to a limit you set, then send their picks with a note. You can watermark what clients see. See [Client proofing and watermarks](https://steadylink.io/docs/guides/client-proofing).
- **Redesigned collection pages.** Shared collections are built around the files, with photos in a full-width grid and a full-screen viewer, other files in a list with copy and download, and video and audio that play in the page.
- **Add to collection** from a file's menu, the file panel, or a bulk selection.
- **New webhook events.** `request.submitted` fires when someone finishes a request link submission, and `proofing.submitted` fires when a client sends their picks.
- **A redesigned dashboard and site.** A new dashboard layout, plus a refreshed public site, emails, and share images.
- **Personal plan storage is now 50 GB.**
- **A faster start for new accounts.** Sign up with Google or GitHub in one step, and upload and share your first file before verifying your email.
- **Renaming keeps the file extension**, and warns with a one-click fix if you change it. Files with non-English characters in their names now download correctly.
- **Send feedback** from the dashboard with the new feedback button.
- **Security.** The first sign-in through single sign-on is now confirmed by email before it links to an existing account, and SSO enforcement applies everywhere in the app. Signup and password reset are protected against bots, and webhook deliveries no longer follow redirects. The Terms and Privacy Policy were updated on October 26.
##### Custom domains, protected links, and link stats, October 19
- **Custom domains.** Serve your links from your own subdomain, like `files.yoursite.com`, with one CNAME record and an SSL certificate issued for you. Make a domain the default and copied links use it, or pick a domain per link. Older `cdn.steadylink.io` links keep working. Personal includes 1 domain, Pro 3, and Business 10.
- **Password-protected links** (Personal and up). Add a password when you create a private link. Visitors enter it on a SteadyLink page before the file opens, and changing or removing the password signs everyone out. Private links can also use any expiry, not only the presets.
- **QR codes with scan counts.** On paid plans, every public file has a QR code that opens its stable link, so replacing the file never means reprinting. File details show scans from the last 30 days.
- **Per-link stats.** File details show requests and bandwidth for every link. Paid plans add traffic sources, visitor countries, and a daily trend.
- **Scheduled swaps** (Pro). Pick a version and a time, and the link switches to it then, with an optional time to switch back.
- **Broken-link alerts** (Pro). Get notified when a link starts returning errors to visitors. Turn it on in **Settings > Notifications**.
- **Remove SteadyLink branding** from collections and other shared pages in workspace settings.
- **Renames keep links over S3.** Renaming a file or folder with rclone or another S3 tool now keeps each file's stable link and version history. Before, a rename created a new file with a new link.
- **Videos and audio play in the browser.** Private links to video and audio files now play inline instead of downloading.
- **Conversions.** Conversions start faster, iPhone HEIC photos convert reliably, failed conversions stay in your list with the reason, and finished ones have a **Download** button. The format picker shows formats as chips and works in dark mode.
- Paid features are visible on every plan. Hovering or tapping one explains which plan includes it, with a button to upgrade.
##### Drives, bulk actions, and faster scanning, October 8
- **Connect rclone in one command.** Create an S3 key in **Developers > S3 & rclone** and paste the command it shows. The installer sets up rclone, can mount SteadyLink as a drive (`S:` on Windows), and can schedule backups. The drive starts at sign-in and restarts itself if it stops. See [Connect rclone](https://steadylink.io/docs/guides/rclone) and [github.com/SteadyLink-io/rclone-setup](https://github.com/SteadyLink-io/rclone-setup).
- **Readable bucket names over S3.** Buckets now have S3 names like `marketing` and `my-files` instead of IDs. Mounted drives show the names you typed, and **New folder** at the top of a drive creates a bucket. Existing setups that use bucket IDs keep working.
- **Much faster deletes.** Deleting a file takes a fraction of a second instead of about three, from the dashboard, the API, or a mounted drive. Deleting a folder now removes every file in it from delivery.
- **Bulk actions in Files.** Select every file that matches your filters, not just one page, then delete, move, or change visibility in a few requests. Filter by bucket, folder, type, size, date, and visibility, show up to all files on one page, and move files between buckets without changing their links.
- **Faster malware scanning.** Small files are usually scanned before the upload finishes, files we've already scanned aren't scanned again, and files up to 1 GB are supported. Large archives no longer get stuck retrying.
- **Redesigned emails.** Every email uses a new layout that works across mail apps and in dark mode. New security emails tell you when two-step verification is turned on or off and when a recovery code is used. Alerts link straight to the file, and the daily summary now sends once a day as intended.
- **Two-step verification setup** is now a short two-step flow in **Settings > Security**, with recovery codes you can copy or download.
##### Documentation, October 8
- New documentation at [/docs](https://steadylink.io/docs), organized into guides, concepts, reference, and security, with search (Ctrl K), light and dark themes, and examples in curl, JavaScript, Python, and the CLI side by side.
- New pages on [stable links](https://steadylink.io/docs/concepts/stable-links), the [upload lifecycle](https://steadylink.io/docs/concepts/uploads), [workspaces and roles](https://steadylink.io/docs/concepts/workspaces-and-roles), [two-step verification](https://steadylink.io/docs/security/two-factor), [API key safety](https://steadylink.io/docs/security/api-keys), and [single sign-on](https://steadylink.io/docs/security/single-sign-on).
- Corrections to the previous docs: deleting a file's last revision deletes the file (it was described as not allowed), management API usage counts API-key requests only, and API keys with `workspace:admin` cannot manage members, invitations, or other keys.
- Links to the old `/docs/users/...` and `/docs/developers/...` pages redirect to their new locations.
##### Developer tools, two-step verification, and S3, October 2
- **S3-compatible API.** Use rclone, restic, Cyberduck, the AWS CLI, and other S3 tools with your buckets through per-person app keys that can be read-only or limited to one bucket. See [S3-compatible API](https://steadylink.io/docs/reference/s3).
- **Two-step verification.** Turn on authenticator app codes in **Settings > Security**, with ten one-time recovery codes. Sign-in asks for a code after your password, Google, or GitHub. See [Two-step verification](https://steadylink.io/docs/security/two-factor).
- **TypeScript SDK 0.2.0.** `upload()` for one or many files with progress and a stable link in every result, `replace()` by asset ID, link, or bucket and key, signed-link helpers, revision helpers, collections, and a `next/image` loader. Node.js 18 is now the minimum. See [TypeScript SDK](https://steadylink.io/docs/reference/sdk).
- **CLI and GitHub Action.** `@steadylink/cli` uploads, replaces, and prints links from a terminal or CI job. The GitHub Action publishes release files and keeps their download links. See [CLI](https://steadylink.io/docs/reference/cli) and [GitHub Action](https://steadylink.io/docs/reference/github-action).
- **More free storage and delivery.** The Free plan now includes 10 GB of storage and 50 GB of delivery per month.
- **Undo for deletes.** Deleting files in the dashboard shows progress in a bottom activity bar, with an undo option.
- **Dashboard updates.** A first-run checklist replaces the forced onboarding, links appear right after upload, widget uploads have a review queue, and paid plan checkout works from the dashboard again.
#### September 2026
##### Analytics and conversions, September 27
- **Traffic sources.** Analytics and file details now show where delivery traffic comes from: search engines, social sites, other websites, tagged links (`ref` or `utm_source`), link previews, and direct visits.
- **Redesigned dashboard.** A new layout and design across the dashboard, with fixes for an analytics error and for file previews that failed to load.
- **PDF to images converts every page**, not only the first.
- **Clearing finished conversions.** **Delete** and **Clear completed** now remove finished conversions.
- **More reliable malware scanning.** Scan time limits scale with file size, and scans wait for the scanner to be ready instead of failing, so large files are less likely to end with a scan error.
- **HEIC images** that could not be decoded before now fall back to a second decoder.
#### August 2026
##### Dashboard and sharing fixes, August 27
- The file browser is more compact and paginated, and dashboard search covers the whole workspace.
- Copied file links respect privacy: copying a private file's link creates a signed link instead of a link that would return `403`.
- Fixes for replacement routing, thumbnails, previews, analytics, and migration feedback.
##### Workflows, sign-in, and sharing, August 22
- **Replacement requests and scheduled revisions.** Ask someone outside your workspace for a new version of one file, review it, and publish it now or at a scheduled time, with an optional rollback. See [Replacement requests](https://steadylink.io/docs/guides/replacement-requests).
- **Asset request forms.** Collect several requested files through one expiring link. See [Asset requests](https://steadylink.io/docs/guides/asset-requests).
- **Website migration.** Scan a public site, review its media, and import selected files into a folder, with a map from old URLs to new stable links. See [Website migration](https://steadylink.io/docs/guides/website-migration).
- **Upload widgets and collections.** Accept uploads from your own site, with review before anything is published, and share groups of files through branded portals. See [Upload widget](https://steadylink.io/docs/guides/upload-widget) and [Collections and portals](https://steadylink.io/docs/guides/collections-and-portals).
- **Notifications.** Choose which workspace events reach you and how. See [Notifications](https://steadylink.io/docs/guides/notifications).
- **Google and GitHub sign-in**, profile names, and profile and workspace pictures.
##### Image showcase, August 21
- Add `showcase` to an image's delivery link to open it on a simple branded page instead of as a bare file.
##### Platform controls and SDKs, August 20
- **Python SDK 0.1.0** (`steadylink` on PyPI) and **TypeScript SDK 0.1.0** (`@steadylink/sdk`). See [Python SDK](https://steadylink.io/docs/reference/python-sdk).
- **Platform controls.** Delivery policy by country, focal points, webhooks with Slack, Discord, and Teams destinations, and single sign-on with a required test before enforcement. See [Platform API](https://steadylink.io/docs/reference/api/platform).
- **Workspace roles.** Owner, admin, member, and viewer roles are enforced across the dashboard and API. See [Workspaces and roles](https://steadylink.io/docs/concepts/workspaces-and-roles).
##### Plans, billing, and usage, August 17 to 19
- Paid plans with checkout and a subscription management portal.
- Usage governance: storage, delivery, management API operations, and conversion credits are metered separately, with notifications at 50, 80, and 100 percent and an owner-chosen rule at the limit. See [Usage and limits](https://steadylink.io/docs/concepts/usage-and-limits).
- File conversions for uploads and stored workspace files, with a credit model and saved results. See [File conversions](https://steadylink.io/docs/guides/file-conversions).
- Password reset by email.
##### Foundations, August 15 and 16
- **Revocable signed links.** Signed links are now named, listable grants that can be revoked before they expire. See [Private links](https://steadylink.io/docs/security/private-links).
- **Workspaces and scoped API keys.** Invitations, roles, and API keys limited to specific scopes.
- **Revocable sessions.** Signed-in browsers can be listed and signed out.
- **Storage analytics** from each bucket down to each file's current and retained revisions.
- **Reliable uploads.** Upload finalization survives restarts and retries, and re-uploading to the same path always adds a revision to the existing file instead of replacing its identity.
- **Workspace themes** and previews in the dashboard that do not count as delivery.
#### Next steps
- [Quickstart](https://steadylink.io/docs/quickstart): try the current product in five minutes.
- [Security overview](https://steadylink.io/docs/security): how uploads, links, and accounts are protected today.
## Engineering notes
### SteadyLink Storage Architecture (Overview)
Source: https://steadylink.io/blog/architecture-overview
This overview traces a file request through SteadyLink's edge, API, database, and object storage. It also explains where access checks, caching, and background jobs fit into that path.

## Why this architecture
A public asset URL identifies the file rather than a storage location. That keeps embeds intact when a new revision is published. The edge caches responses, the API streams cache misses from object storage, and short-lived tokens control access to private files without exposing storage URLs.
## From request to first byte
When a user opens a canonical URL like `/a/{id}`, the edge looks for a cached copy. On a hit, the response is immediate. On a miss, the API verifies access and streams bytes directly from object storage. Strong validators (ETag) and cache headers allow the edge to warm quickly and serve repeat traffic close to users.
This path is simple by design: stable links, a small number of hops, and no vendor URLs in the wild.
## Uploads without bottlenecks
Uploads start at the API, which issues a short‑lived presigned URL. The browser then uploads bytes directly to storage. Afterward, a commit call records the new version in the database and attaches metadata. When common variants are helpful, such as web‑ready sizes, the commit enqueues background work so the UI stays responsive.
Direct‑to‑storage means fewer retries, predictable throughput, and clear separation between metadata and bytes.
## Background work, kept off the critical path
A separate worker precomputes common transforms, sweeps temporary uploads, and purges old entries. None of that work needs to delay a file request.

## Privacy and access control
SteadyLink supports both public and private delivery. Private assets are served via signed access tokens and organization scoping. Sensitive actions are auditable so teams can review activity and track usage without exposing global data. Raw storage URLs are not surfaced.

## Reliability and performance posture
Edge caching near users, efficient streaming from storage, and versioned URLs for immutable assets keep the system predictable. Database indices support quick lookups for assets and versions, while canonical URL design avoids churn in downstream systems and embeds.
## Summary
The delivery URL stays separate from the stored revision. Direct uploads keep file bytes off the API path, edge caching handles repeat requests, and audit records capture sensitive changes.
### How to Change the File Behind a QR Code Without Reprinting It
Source: https://steadylink.io/blog/change-file-behind-qr-code
A QR code stores a URL, not a file. If that URL always serves the current version of your file, you can change the file as often as you like and the printed code keeps working. With SteadyLink, you encode the file's stable link in the QR code, then use Replace whenever the menu, flyer, or manual changes.
## What a QR code actually contains
A QR code is a picture of some text. For most printed codes that text is a web address. When someone scans it, their phone reads the address and opens it in a browser. The phone does not know or care what is on the other end.
That means the code itself can never be edited after printing, but what the address serves can be. The problem is that most file hosting gives each upload its own address. Upload a new menu and you get a new link, which means a new QR code, which means reprinting table cards, posters, packaging, or business cards.
There are two common ways around this:
- A redirect service. The QR code points at a short link you control, and you change where that short link sends people. This works, but it adds a hop, and you still need somewhere to host the file.
- A stable file link. The QR code points straight at the file's permanent address, and you replace the file behind it. One service, no redirect to manage.
This guide covers the second approach.
## Set it up once
1. Upload the file to SteadyLink. Choose Upload in the dashboard, pick a bucket, and set the file so anyone with the link can open it. A QR code that opens a private file will not work for people scanning it.
2. Copy the file's link. It has the form `https://cdn.steadylink.io/a/ASSET_ID`.
3. Paste that link into any QR code generator and download the code as an image, ideally SVG or a large PNG for print.
4. Scan the code with two or three different phones before you print. Check that it opens the file you expect.
5. Print.
The link contains the file's ID, not its name or folder, so renaming or moving the file later does not change it.
## Change the file later
1. Open the file in the dashboard and choose Replace.
2. Pick the new version. When it finishes you see "File replaced. Its link is unchanged."
3. Scan the printed code again to confirm the new version opens.
From a terminal or a script, the CLI does the same:
```sh
steadylink replace print:menus/dinner.pdf ./dinner-menu-autumn.pdf
```
If someone outside your team prepares the new version, such as a designer or a print shop, send them a [replacement request](/docs/guides/replacement-requests). They upload the file, and nothing changes on the live link until you approve it. You can also approve it for a future time, so the new menu goes live when the season changes rather than when you happen to be at a computer.
## Do not encode a pinned link
SteadyLink has two kinds of link for the same file. The stable link, `/a/ASSET_ID`, serves whatever version is current. A pinned link, such as `/a/ASSET_ID?v=3`, always serves revision 3 and never changes.
For a QR code you want to update, encode the stable link. If you copy a link that ends in `?v=` and print it, the code will keep opening that old version forever, no matter how many times you replace the file. Before generating the code, check that the URL ends in the asset ID with nothing after it.
Pinned links are useful for the opposite case: a code on a certificate or a signed document that should always open exactly the version that was issued.
## Expect a short changeover
After a replacement, SteadyLink serves the new version immediately, but the stable link is cacheable for five minutes with a short background refresh after that. A phone or network cache that opened the file a moment ago may show the old version briefly. Anyone scanning for the first time gets the new one.
Right after a replacement the new file is also scanned for malware, which usually takes seconds. During that time the link answers with a "locked" response instead of the old file. For a busy code, like a menu during service, replace the file during a quiet moment or schedule the change through a replacement request.
## Practical tips for printed codes
- Keep the content type consistent. SteadyLink allows replacing a PDF with an image, but if people expect a PDF menu, keep publishing PDFs so phones handle it the same way each time.
- Keep the file small enough to open quickly on a phone connection. Compress large scans before uploading.
- Print the code at a size that scans easily, with a clear margin around it.
- Put a short line of text next to the code saying what it opens, such as "Scan for today's menu".
- If you print many codes for different files, keep a list of which asset ID each one points at. The dashboard shows each file's link.
## If something goes wrong
If you publish the wrong file, open it, go to Version history, and choose Restore on the previous revision. The printed code goes back to opening the older version. The only things that break a stable link are deleting the file or its bucket, or making the file private, so avoid those for any file that is printed somewhere.
## Related reading
The same approach works for any file you share by link. See [how to update a PDF without changing the link](/blog/update-pdf-without-changing-link), [how to get a permanent link to a file you can update](/blog/permanent-link-to-file-you-can-update), and the [stable links concept page](/docs/concepts/stable-links), which covers caching and pinned revisions in detail. You can [sign up for the Free plan](/signup) to try it with one code.
### Fast Asset Delivery with Canonical URLs
Source: https://steadylink.io/blog/delivery-caching
We serve assets through canonical routes and cache them aggressively at the edge.
- CDN caching with ETag and long-lived Cache-Control for immutable versions
- Conditional requests (304) for instant validations
- Streaming bytes directly from object storage for low TTFB
- Tokens for private delivery without leaking storage URLs
This post covers the delivery path, cache headers, and privacy controls at a high level.
### How to Get a Permanent Link to a File You Can Update
Source: https://steadylink.io/blog/permanent-link-to-file-you-can-update
You need a host that gives each file an address based on the file's identity rather than its contents or filename, and that stores new uploads as versions of the same file. SteadyLink does this: every file gets one link of the form `https://cdn.steadylink.io/a/ASSET_ID`, and when you replace the file, that link serves the new version while older versions stay in its history.
## What "permanent" should mean
A link to a file you update has to survive a few different kinds of change:
- New contents. The document, image, or installer is updated.
- A new filename. The new version is called `report-q3.pdf` instead of `report-q2.pdf`.
- Reorganizing. The file moves to a different folder or gets renamed.
- Time. The link still works in a year, from an email or a printed page nobody can edit.
Many hosts handle some of these but not all. A link that includes the filename or folder path breaks when either changes. A host that gives every upload a fresh address breaks on new contents. A link that only works while you are logged in, or that expires after a set time, breaks on time.
A SteadyLink link is built from the file ID, which is assigned when the file is first uploaded and never changes. Replacing the file, renaming it, or moving it inside its bucket keeps the same ID and the same link.
## Get the link
1. Sign up and open the dashboard. If you have no buckets yet, one called My files is created for you.
2. Choose Upload, pick the file, and choose who can open the link: Anyone with the link, or Private.
3. When the upload finishes, choose Copy link.
That link is the one to share, embed, or print. You can also find it any time by opening the file in the dashboard.
From a terminal, the CLI uploads and prints the link:
```sh
steadylink upload ./report.pdf --bucket reports --folder quarterly
```
## Update the file behind the link
In the dashboard, open the file and choose Replace, then pick the new version. You see "File replaced. Its link is unchanged." when it is done.
From the CLI:
```sh
steadylink replace reports:quarterly/report.pdf ./report-q3.pdf
```
Uploading a file with the same name into the same folder also adds a new version to the existing file instead of creating a copy, so a deploy script that re-uploads the same path keeps the link stable.
## Two links for two jobs
Each file has two kinds of link, and both are permanent in their own way.
| Link | What it serves | Use it for |
|---|---|---|
| `/a/ASSET_ID` | The current version, whatever it is right now | Websites, emails, QR codes, anything that should stay up to date |
| `/a/ASSET_ID?v=3` | Revision 3, always | Signed documents, releases, records that must not change |
The stable link changes what it serves when you replace the file. The pinned link never does, for as long as that revision is kept. Sharing both from the same file means you do not have to keep separate copies for "latest" and "as of this date".
## What to expect after an update
SteadyLink switches to the new version immediately. Browsers and caches that fetched the old version in the last few minutes may keep showing it until their copy expires; the stable link is cacheable for five minutes, plus a short background refresh. There is also a brief malware scan after each upload, usually seconds, during which the link answers `423 Locked` rather than serving unscanned bytes.
If you need the change to land at a set time, use a [replacement request](/docs/guides/replacement-requests) with a scheduled publish time.
## When a permanent link can still stop working
A stable link only stops working when the file or access to it goes away on purpose:
- The file is deleted, or its last remaining revision is deleted.
- The bucket holding the file is deleted.
- The file is switched to private, so requests without a valid signed link are refused.
- A new version fails the malware scan, which leaves the file without a current version until you restore an earlier one.
Deleting a file or bucket is immediate and cannot be undone, so treat files that are linked from printed or sent material with care.
## Private files you can still update
If the file should not be public, make it private and share signed links. Replacing a private file keeps it private, and signed links that are not pinned to a revision serve the new version. Signed links can be revoked, which takes effect immediately because private responses are never stored in shared caches. See [private links](/docs/security/private-links).
## Where people use this
- A price list or rate card linked from sales emails
- A logo that partners embed on their sites
- A user manual linked from a printed QR code
- A downloadable installer behind a website button
- A dataset or report linked from documentation
## Related reading
For step-by-step guides for specific files, see [how to replace an image without changing its URL](/blog/replace-image-without-changing-url) and [how to update a PDF without changing the link](/blog/update-pdf-without-changing-link). The [stable links concept page](/docs/concepts/stable-links) explains caching and revisions in more detail, and [pricing](/pricing) lists what each plan includes, including the Free plan.
### How to Replace a File and Keep the Same URL
Source: https://steadylink.io/blog/replace-file-keep-same-url
To replace a file and keep the same URL, the URL has to point at the file's identity, not at one stored copy of it. In SteadyLink every file has a stable link, `https://cdn.steadylink.io/a/ASSET_ID`; replacing the file publishes the new bytes as its next revision, and the same link starts serving them. Older revisions stay available, so you can roll back.
## Why replacing usually changes the URL
Most file hosts build a URL from where the file was stored: a bucket path, a filename, or a random key generated at upload time. When you upload a new version, you often get a new key and therefore a new URL. Now every page, email, app config, and document that used the old one is out of date.
Overwriting the file at the old path sounds like the fix, but it has its own problems. Some hosts do not allow it. Others allow it but leave long-lived caches serving the old bytes, because to a cache the address looks unchanged. Teams then add version numbers to filenames to break the cache, which brings back the original problem: a new URL for every change.
SteadyLink keeps the two ideas apart. The file ID, and the link built from it, are assigned once. The bytes live in revisions, and exactly one revision is current at a time.
## Replace a file in the dashboard
1. Open the file in the dashboard.
2. Choose Replace. The same button is on the Version history tab.
3. Pick the new file. Progress appears in the Activity panel.
4. 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: it becomes the next revision of the existing file rather than a second copy.
## Replace a file from the CLI
```sh
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
```
The target is the file being replaced, written as `bucket:key`, an asset ID, or the link itself. The second argument is the new file on disk. With `--json`, the command prints the new version number, asset ID, and URL, which is handy in CI.
## Replace a file with the API
The HTTP API does it in three steps: request a temporary upload URL in the file's bucket, PUT the new bytes there, and publish them as the next revision of the key.
```sh
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")
curl -X PUT "$(echo "$TEMP" | jq -r '.uploadUrl')" \
-H "Content-Type: application/octet-stream" \
--data-binary @price-list-2026.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"
```
The response is `{ "replaced": true, "version": 4 }`. The replace call finishes synchronously, so when it returns, revision 4 exists and is current. The temporary upload URL is valid for 15 minutes. The JavaScript and Python SDKs wrap the same steps in a single `replace` call; see the [upload and replace guide](/docs/guides/upload-and-replace).
## What stays the same and what changes
| Stays the same | Changes |
|---|---|
| Asset ID and link | Revision number, up by one |
| Bucket, folder, and key | Bytes, size, and content type |
| Public or private visibility | Stored filename, if the new file has a different name |
| Signed links not pinned to a revision | Resized image variants, generated fresh for the new revision |
| Earlier revisions, until you delete them | |
The new file does not have to be the same type as the old one. If you replace a PDF with a PNG, the link starts answering with `image/png`. That is flexible, but check the type before publishing if other systems expect a particular format.
## Timing after a replacement
- Caching: the stable link is cacheable for five minutes, plus a short background refresh window. Anyone who fetched the old version recently may see it until their cached copy expires.
- Scanning: the new revision is scanned for malware right after it is published. Until the scan finishes, usually seconds, the link responds with `423 Locked` rather than serving unscanned bytes.
- Verifying: open the pinned link for the new revision, such as `?v=4`, to confirm the new bytes are live without waiting for caches.
If a short gap is not acceptable for a busy file, publish on a schedule with a [replacement request](/docs/guides/replacement-requests).
## Roll back
Open the file, go to Version history, and choose Restore on the revision you want. From the CLI:
```sh
steadylink versions 3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b
steadylink rollback 3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b 3
```
Restoring makes an older revision current again without creating a new revision number, and the link does not change.
## When you want the URL to stay fixed to one version
Sometimes the opposite is what you need: a link that keeps serving exactly one version even after the file is replaced. Use a pinned link, `https://cdn.steadylink.io/a/ASSET_ID?v=3`. It serves revision 3 for as long as that revision is retained.
## Related reading
For specific file types, see [how to replace an image without changing its URL](/blog/replace-image-without-changing-url) and [how to update a PDF without changing the link](/blog/update-pdf-without-changing-link). The [stable links concept page](/docs/concepts/stable-links) covers the identity and revision model in full, and [pricing](/pricing) shows the plan limits, starting with the Free plan.
### How to Replace an Image Without Changing Its URL
Source: https://steadylink.io/blog/replace-image-without-changing-url
Host the image somewhere that gives it a stable URL tied to the file's identity rather than its bytes, then upload the new version as a replacement instead of as a new file. In SteadyLink, every file gets a link like `https://cdn.steadylink.io/a/ASSET_ID`; when you choose Replace and upload new artwork, that same link starts serving the new image, and every page, email, and app that uses it updates without being edited. Caches can show the old image for up to about five minutes after the change.
## Replace an image in the dashboard
1. Upload the original image once, from Upload in the dashboard or by dragging it onto a bucket in Files.
2. Copy its link and use it wherever the image needs to appear: your site, a CMS, an email template, a document.
3. When the image changes, open the file in the dashboard and choose Replace.
4. Pick the new image. Progress appears in the Activity panel, and when it finishes you see "File replaced. Its link is unchanged."
5. Open the link to check the result. If you still see the old image, wait a few minutes or hard-refresh.
The Replace button is also on the file's Version history tab. Uploading an image with the same name into the same folder has the same effect: it becomes the next revision of the existing file rather than a copy.
## Replace an image with the API or CLI
The CLI takes the file to replace, either as `bucket:key` or as its asset ID or link, followed by the new file.
```sh
steadylink replace marketing:campaign/hero.webp ./hero-v2.webp
# or by asset ID or link
steadylink replace 3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b ./hero-v2.webp
```
With the JavaScript SDK:
```ts
import { SteadyLink } from "@steadylink/sdk";
import { fileFromPath } from "@steadylink/sdk/node";
const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! });
const result = await steadylink.replace(
{ bucketId, key: "campaign/hero.webp" },
await fileFromPath("./hero-v2.webp"),
);
console.log(result.version, result.url);
```
The raw API is three calls: ask for a temporary upload URL, PUT the bytes to it, then publish them as the next revision of the existing key.
```sh
TEMP=$(curl -s -X POST "https://api.steadylink.io/api/assets/$BUCKET_ID/objects/upload-temp?size=284102&content_type=image/webp" \
-H "X-API-Key: $STEADYLINK_API_KEY")
curl -X PUT "$(echo "$TEMP" | jq -r '.uploadUrl')" \
-H "Content-Type: application/octet-stream" \
--data-binary @hero-v2.webp
curl -X POST "https://api.steadylink.io/api/assets/$BUCKET_ID/objects/replace?key=campaign/hero.webp&upload_temp_key=$(echo "$TEMP" | jq -r '.tempKey')&original_filename=hero-v2.webp" \
-H "X-API-Key: $STEADYLINK_API_KEY"
```
The replace call returns `{ "replaced": true, "version": 4 }` once the new revision exists and is current. The full walkthrough is in the [upload and replace guide](/docs/guides/upload-and-replace).
## Why ordinary image URLs become a maintenance problem
A conventional storage URL usually describes where a specific file was written. A new file often produces a new key or filename.
```text
https://storage.example.com/campaign/hero-v1.webp
https://storage.example.com/campaign/hero-v2.webp
```
Publishing the second URL means finding every use of the first one. Overwriting the bytes at the first URL avoids that work, but browsers and CDNs may keep serving the cached response because the address looks identical.
Teams often work around this by adding a date, version number, or hash to every filename. That suits immutable build output, but it is awkward for an image that needs a durable public address. A stable URL separates the public address from the stored revision: the address identifies the image, and a replacement creates another revision behind it.
## Use the image in HTML
The stable URL works like any other image source.
```html
```
The same address can go in a CMS, database, email template, mobile app, README, or document. None of them need to know which revision is current.
## Use the image in Next.js
Allow the SteadyLink delivery hostname in `next.config.ts`.
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.steadylink.io",
pathname: "/a/**",
},
],
},
};
export default nextConfig;
```
Then render the stable URL with the built-in image component.
```tsx
import Image from "next/image";
const imageUrl = process.env.NEXT_PUBLIC_STEADYLINK_ASSET_URL!;
export default function CampaignHero() {
return (
);
}
```
The `unoptimized` property keeps delivery on the SteadyLink URL instead of placing another image cache in front of it. The component does not change when a new revision is published. A complete project is in the [Next.js stable image example](https://github.com/SteadyLink-io/nextjs-stable-images), and the [Next.js images guide](/docs/guides/nextjs-images) covers the setup in more detail.
## Account for caching
Replacing an image does not clear every cache at the same instant. The stable link is cacheable for five minutes, with a short background refresh window after that, so a browser or edge that fetched the old image recently can keep showing it until its copy expires. SteadyLink itself switches to the new revision immediately.
To check that the new image is live without waiting, open the pinned link for the new revision, such as `?v=4`. Pinned links are separate cache entries.
There is also a short scan window. A new revision is scanned for malware right after it is published, and until the scan finishes the stable link answers `423 Locked` rather than serving unscanned bytes. Scans usually take seconds. If a gap matters for a busy image, schedule the change with a [replacement request](/docs/guides/replacement-requests) instead.
## Pin a revision when the image must not change
When a consumer needs one exact revision indefinitely, add the revision number to the URL.
```text
https://cdn.steadylink.io/a/ASSET_ID?v=4
```
Pinned responses are immutable and cached for a year. They keep serving revision 4 for as long as that revision is retained, even after later replacements.
## When to use which link
Use the stable URL when:
- A logo or campaign image will change over time
- The URL is stored in systems that are hard to update
- The image appears in emails, documents, or third-party embeds
- You want rollback without republishing links
- Several applications should follow the same current version
Use a pinned `?v=` URL when:
- The exact bytes are part of a historical record
- A release must stay reproducible
- A cache should keep the file indefinitely
- A consumer should not follow later replacements
Both forms come from the same revision history, so you do not need separate storage for changing and fixed images.
## Roll back a bad replacement
Earlier revisions stay in the file's Version history. If the new image is wrong, open the file, go to Version history, and choose Restore on the previous revision. From the CLI, `steadylink rollback ASSET_ID 3` does the same. Restoring moves the current pointer back without changing the link.
## Related guides
The same approach works for any file type, not just images. See [how to replace a file and keep the same URL](/blog/replace-file-keep-same-url), [how to update a PDF without changing the link](/blog/update-pdf-without-changing-link), and [how to change the file behind a QR code](/blog/change-file-behind-qr-code). The [stable links concept page](/docs/concepts/stable-links) explains the identity and revision model, and [private links](/docs/security/private-links) covers public, private, and signed access.
### How to Update a PDF Without Changing the Link
Source: https://steadylink.io/blog/update-pdf-without-changing-link
To update a PDF without changing its link, host it somewhere that keeps one permanent URL per file and publishes new uploads as revisions of that file. In SteadyLink you upload the PDF once, share its link, and later choose Replace on the same file; the link stays the same and starts serving the new PDF, usually within about five minutes for anyone who opened it recently.
## Why PDF links are hard to keep current
PDFs tend to travel. A price list gets linked from a hundred sent emails. A menu goes into a QR code on every table. A product spec sheet gets pasted into a partner's portal, a support article, and a sales deck. A policy document is linked from an intranet page nobody remembers editing.
When the PDF changes, the usual options are all a bit painful:
- Upload a new file, get a new link, and hunt down every place the old link was used. Emails that already went out cannot be fixed.
- Overwrite the file on a web server and hope nothing serves a cached copy. Some hosts do not let you overwrite at all, or give the new upload a new address.
- Put a page in front of the PDF and update the page. That works, but it adds a click and something else to maintain.
A stable link avoids all of this. The link points at the file's identity, and the identity outlives any single version of the document.
## Update a PDF in the dashboard
1. Upload the current PDF. Choose Upload in the dashboard, pick a bucket and optionally a folder, choose whether anyone with the link can open it, and upload.
2. Copy the link. It looks like `https://cdn.steadylink.io/a/ASSET_ID`. This is the address you put in emails, web pages, documents, and QR codes.
3. When the PDF changes, open the file in the dashboard and choose Replace.
4. Pick the new PDF. When it finishes, you see "File replaced. Its link is unchanged."
5. Open the link in a private window to confirm the new version is live.
The new file does not need the same filename. If you pick `price-list-2026.pdf` to replace `price-list.pdf`, the link stays the same; the stored filename changes, and browsers use it when someone saves the file.
## Update a PDF from a script
If the PDF is generated by a build or a reporting job, publish it from the CLI. The first argument is the file to replace, as `bucket:key` or an asset ID or link.
```sh
steadylink replace marketing:docs/price-list.pdf ./price-list-2026.pdf
```
The same thing in Python:
```python
from pathlib import Path
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"])
```
The call returns once the new revision exists and is current, with its revision number. The HTTP API version and the JavaScript SDK are covered in the [upload and replace guide](/docs/guides/upload-and-replace).
## Let someone else send the new version
Often the person with the updated PDF is not on your team: an agency, a supplier, a colleague in another department. A [replacement request](/docs/guides/replacement-requests) is a single-purpose upload link for one existing file. They upload a candidate, nothing changes on the live link, and someone in your workspace approves it. Approval can publish right away or at a scheduled time, which is useful when a new price list should go live on the first of the month.
## What people see after the update
SteadyLink starts serving the new revision immediately, but the stable link is cacheable for five minutes, with a short background refresh after that. Someone who opened the PDF a minute before the replacement may see the old version until their copy expires. Anyone opening it for the first time gets the new one.
Right after a replacement the new revision is scanned for malware. Until the scan finishes, which usually takes seconds, the link answers `423 Locked` instead of serving unscanned bytes. If even that gap matters, schedule the change through a replacement request.
People who already downloaded the PDF keep their local copy. A stable link updates what the link serves, not files already saved to someone's computer.
## Keep an exact copy when it matters
Some PDFs should not change after the fact: a signed agreement, the terms that applied on a given date, a report submitted to a regulator. For those, use a pinned link that names one revision.
```text
https://cdn.steadylink.io/a/ASSET_ID?v=3
```
A pinned link keeps serving revision 3 for as long as that revision is retained, no matter how many times the file is replaced later. You can share the stable link for "the latest terms" and a pinned link for "the terms you agreed to", both from the same file. How long older revisions are kept depends on your plan; on the [Free plan](/pricing) revision history is kept for 30 days.
## Undo a mistake
If you replace the PDF with the wrong file, open it, go to Version history, and choose Restore on the previous revision. The link goes back to serving the older PDF without anyone needing a new address. From the CLI, `steadylink rollback ASSET_ID 3` does the same.
## Things to check before you rely on it
- Make sure the link you share is the stable one, without `?v=`. A pinned link will never pick up the new version.
- Check visibility. A public file opens for anyone with the link; a private file needs a signed link, and replacing it keeps its visibility.
- Check the file size against your plan. The Free plan accepts files up to 100 MB.
- SteadyLink does not force the replacement to be a PDF. If you replace it with an image by mistake, the link starts serving an image, so check the file before you publish.
## Related reading
The same steps apply to any file type. See [how to replace a file and keep the same URL](/blog/replace-file-keep-same-url), [how to change the file behind a QR code](/blog/change-file-behind-qr-code), and the [stable links concept page](/docs/concepts/stable-links). You can [start on the Free plan](/signup) and try it with one PDF you update often.