# 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 | | --- | --- | | ``, ``, `