Skip to content

Uploads

Upload up to 100 files per batch, send bytes straight to storage or through the API, complete each session safely, and track finalization and scanning by polling or live events.

On this page

This page is for developers adding files to SteadyLink from their own code. An upload is a short conversation: you announce the files, send each file's bytes, tell SteadyLink you are done, and wait while it verifies, scans, and publishes them. At the end every file has an asset ID and a stable link.

To publish new bytes for a file that already exists, keep its link and use Replace a file instead.

The JavaScript examples assume const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! }) and the Python examples assume client = SteadyLink(api_key=os.environ["STEADYLINK_API_KEY"]).

How an upload works#

Createupload batchUploadPUT the bytesCompletefinalize sessionScanmalware + policyreadyblockedfailed
Each file in a batch moves through these states on its own. Ready, blocked, failed, and cancelled are final.
  1. Create a batch

    POST /api/upload-batches with the bucket and up to 100 files. SteadyLink reserves storage for them and returns one upload session per file, each with a presigned uploadUrl.

  2. Send the bytes

    PUT each file to its uploadUrl. The bytes go straight to object storage, not through the API, so large files do not tie up your API connection or count against API request limits.

  3. Complete each session

    POST /api/upload-sessions/{session_id}/complete with an Idempotency-Key. SteadyLink queues finalization and answers 202 immediately.

  4. Wait for a final state

    Poll the batch or listen to the event stream until each session is ready, blocked, failed, or cancelled. A ready session has an objectAssetId: the file's stable link is https://cdn.steadylink.io/a/{objectAssetId}.

If you use the SDK or CLI, one call does all four steps:

import { fileFromPath } from "@steadylink/sdk/node";

const [hero] = await steadylink.upload(
  "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
  await fileFromPath("./campaign-hero.webp"),
  { folder: "campaign/launch", visibility: "public" },
);
console.log(hero.status, hero.url); // "ready", https://cdn.steadylink.io/a/3f2a9c1e-...

The Python helper returns as soon as the session is completed, before finalization. Poll Get an upload batch with the session's batchId to get the asset ID. Leave its content_type at the default: presigned URLs are signed for application/octet-stream, and the stored type is detected from the bytes.

Upload states#

Every upload session has a status:

StatusFinalMeaning
createdNoThe session exists and is waiting for bytes. Direct storage uploads stay created until you complete the session, because storage does not notify the API.
uploadedNoBytes were received through Upload bytes through the API.
committingNoYou completed the session and finalization is queued or running: size checks, type detection, bucket policy, and revision creation. If finalization hits a temporary problem, the session stays here with the error code commit_retrying and is retried automatically.
scanningNoThe revision exists and the malware scan is running. objectAssetId and revisionNumber are already set, but the link is not served until the scan is clean.
readyYesThe file is published under its visibility. Its link works.
blockedYesMalware was found or the bucket's upload policy rejected the file. Nothing was published.
failedYesFinalization could not finish. Upload the file again in a new batch.
cancelledYesThe session was cancelled before it finished.

When a session is blocked or failed, its error explains why:

error.codeStatusCause and fix
malware_detectedblockedThe scanner found malware. The bytes are discarded.
policyblockedThe bucket's max_upload_mb, allowed_mime_prefixes, or allowed_extensions rejected the file. See Update bucket settings.
upload_expiredfailedThe session was completed after expiresAt. Create a new batch.
upload_missingfailedNo bytes were found when finalization ran. The PUT never happened, failed, or the temporary bytes expired.
upload_size_mismatchfailedThe uploaded bytes do not match the declared size. Declare the exact size.
upload_too_largefailedThe uploaded bytes exceed the upload limit that applied when the batch was created.

A batch has its own status: active while any session is not final, complete when every session is final and none is blocked or failed, and partial when every session is final and at least one is blocked or failed. A batch with only ready and cancelled sessions is complete.

Operations#

Create an upload batch#

POST/api/upload-batches
Requiresassets:write

Creates one upload session per file and returns presigned upload URLs. All files go into one bucket; each can have its own folder.

Body

bucketIduuidRequired
Destination bucket. A file asset ID returns 400 Uploads require a bucket.
filesobject[]Required
1 to 100 files. Split larger sets into several batches.
files[].filenamestringRequired
File name, 1 to 512 characters, for example campaign-hero.webp. Any directory part is removed: a/b/c.png becomes c.png. Use path for folders.
files[].sizeintegerRequired
Exact size in bytes, at least 1. Missing returns 422 with the code upload_size_required. Empty files cannot be uploaded.
files[].pathstringDefault ""
Folder inside the bucket, up to 1024 characters, for example campaign/launch/. Backslashes become slashes, empty and . segments are dropped, and a trailing slash is added. A .. segment returns 422 Invalid upload path.
files[].contentTypestringDefault application/octet-stream
Declared type, up to 128 characters, recorded on the session. The type stored with the revision is detected from the bytes during finalization, so a wrong value here does not mislabel the file.
curl -X POST "https://api.steadylink.io/api/upload-batches" \
  -H "X-API-Key: $STEADYLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
    "files": [
      { "filename": "campaign-hero.webp", "path": "campaign/launch/", "contentType": "image/webp", "size": 2841024 },
      { "filename": "price-list.pdf", "path": "docs/", "contentType": "application/pdf", "size": 418230 }
    ]
  }'
201 CreatedResponse
{
  "id": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
  "status": "active",
  "totalFiles": 2,
  "files": [
    {
      "id": "9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64",
      "batchId": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
      "bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
      "objectAssetId": null,
      "filename": "campaign-hero.webp",
      "path": "campaign/launch/",
      "contentType": "image/webp",
      "expectedSize": 2841024,
      "status": "created",
      "revisionNumber": null,
      "error": null,
      "expiresAt": "2026-10-09T14:22:31.482913",
      "createdAt": "2026-10-08T14:22:31.482913",
      "updatedAt": "2026-10-08T14:22:31.482913",
      "uploadUrl": "https://storage-endpoint.example/uploads/temp/0c7f3a92-...?X-Amz-Signature=..."
    },
    {
      "id": "2b8f6d14-9a3e-4c71-b0d5-e7f2a1c8b396",
      "batchId": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
      "bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
      "objectAssetId": null,
      "filename": "price-list.pdf",
      "path": "docs/",
      "contentType": "application/pdf",
      "expectedSize": 418230,
      "status": "created",
      "revisionNumber": null,
      "error": null,
      "expiresAt": "2026-10-09T14:22:31.482913",
      "createdAt": "2026-10-08T14:22:31.482913",
      "updatedAt": "2026-10-08T14:22:31.482913",
      "uploadUrl": "https://storage-endpoint.example/uploads/temp/5a1e9c08-...?X-Amz-Signature=..."
    }
  ]
}

files is in the same order as the request. Two clocks matter:

  • uploadUrl expires 15 minutes after the batch is created. Send the bytes promptly. If the URL expires first, cancel the session and create a new batch.
  • expiresAt is when the session itself expires, by default 24 hours after creation. Completing a session after that marks it failed with upload_expired.

The batch is checked as a whole before anything is created:

StatusCodeCause
413upload_size_limitA file is larger than the upload limit: the smaller of the plan's API ceiling and the workspace upload limit. The body's limit is the effective limit in bytes.
413usage_limit_exceededThe batch would put the workspace over its storage allowance. Storage is reserved when the batch is created and released if a session is cancelled, fails, or expires.
422upload_size_requiredA file has no size.
429api_expensive_operation_rate_exceededCreating batches counts as an expensive operation.

Upload the bytes#

PUT{uploadUrl}
Presigned URL. Send no API key.

Send the file's raw bytes to the session's uploadUrl. This request goes to object storage, so storage answers it, not the SteadyLink API.

Headers

Content-TypestringRequired
Exactly application/octet-stream, whatever the file's real type. The URL is signed for this value, and any other value fails with 403 from storage. The stored type is detected from the bytes later.
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @campaign-hero.webp

A successful PUT returns 200 from storage with an empty body. The session stays created: storage does not tell SteadyLink about the upload, so you must complete the session next.

Storage errors come back as XML from storage, not as SteadyLink JSON. 403 with SignatureDoesNotMatch almost always means the wrong Content-Type header; 403 with AccessDenied or Request has expired means the URL is older than 15 minutes.

From a browser, the same PUT works with fetch or XMLHttpRequest, which can report progress. Create the batch and complete the session on your server so the API key never reaches the browser. See Browser uploads.

Upload bytes through the API#

PUT/api/upload-sessions/{session_id}/content
Requiresassets:write

Streams the bytes through the SteadyLink API instead of straight to storage. Use it only when a network blocks the storage host, for example a strict corporate proxy. It authenticates like any API request and moves the session to uploaded.

Headers

Content-Typestring
application/octet-stream.
Content-Lengthinteger
If sent, it must equal the session's expectedSize, or the request returns 400 Uploaded file size does not match.
curl -X PUT "https://api.steadylink.io/api/upload-sessions/9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64/content" \
  -H "X-API-Key: $STEADYLINK_API_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @campaign-hero.webp

The response is the session object with status: "uploaded". Other outcomes:

StatusCause
400The byte count does not match expectedSize, or Content-Length is invalid.
409The session is past created or uploaded, for example already committing.
410The session has expired.
502Storage could not store the bytes. Retry the request.

This route counts against your API rate limits and the request stays open for the whole transfer, so prefer presigned uploads when they work.

Complete an upload session#

POST/api/upload-sessions/{session_id}/complete
Requiresassets:write

Tells SteadyLink the bytes are in place. The session moves to committing and finalization is queued: SteadyLink checks the size, detects the type, applies the bucket's policy, creates the file and its first revision (or a new revision if a file already exists at the same key), and starts the malware scan.

Headers

Idempotency-Keystring
Any string unique to this session, for example complete-9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64. Reuse the same value when you retry.
curl -X POST "https://api.steadylink.io/api/upload-sessions/9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64/complete" \
  -H "X-API-Key: $STEADYLINK_API_KEY" \
  -H "Idempotency-Key: complete-9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64"
202 AcceptedResponse
{
  "id": "9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64",
  "batchId": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
  "bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
  "objectAssetId": null,
  "filename": "campaign-hero.webp",
  "path": "campaign/launch/",
  "contentType": "image/webp",
  "expectedSize": 2841024,
  "status": "committing",
  "revisionNumber": null,
  "error": null,
  "expiresAt": "2026-10-09T14:22:31.482913",
  "createdAt": "2026-10-08T14:22:31.482913",
  "updatedAt": "2026-10-08T14:23:02.917440"
}

How retries behave:

  • The session is already final (ready, blocked, failed, cancelled): the request returns the session as it is, with no new work. Retrying after success is harmless.
  • The session is committing: the request returns 409 Upload is already committing, even with the same Idempotency-Key. Treat this as "your earlier request went through" and poll for the result.
  • A different Idempotency-Key than the one recorded for the session returns 409 Idempotency key mismatch.
  • The session expired: the session is marked failed with upload_expired, its storage reservation is released, and the request returns 410.

Completing a session before the bytes are uploaded does not fail immediately. Finalization runs, finds nothing, and the session ends failed with upload_missing.

Get an upload batch#

GET/api/upload-batches/{batch_id}
Requiresassets:read

Returns the batch with counts and every session's current state. Poll it until the sessions you care about are final.

curl "https://api.steadylink.io/api/upload-batches/b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13" \
  -H "X-API-Key: $STEADYLINK_API_KEY"
200 OKResponse
{
  "id": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
  "status": "partial",
  "totalFiles": 2,
  "completedFiles": 1,
  "failedFiles": 1,
  "files": [
    {
      "id": "9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64",
      "batchId": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
      "bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
      "objectAssetId": "3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71",
      "filename": "campaign-hero.webp",
      "path": "campaign/launch/",
      "contentType": "image/webp",
      "expectedSize": 2841024,
      "status": "ready",
      "revisionNumber": 1,
      "error": null,
      "expiresAt": "2026-10-09T14:22:31.482913",
      "createdAt": "2026-10-08T14:22:31.482913",
      "updatedAt": "2026-10-08T14:23:09.204118"
    },
    {
      "id": "2b8f6d14-9a3e-4c71-b0d5-e7f2a1c8b396",
      "batchId": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
      "bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
      "objectAssetId": null,
      "filename": "price-list.pdf",
      "path": "docs/",
      "contentType": "application/pdf",
      "expectedSize": 418230,
      "status": "blocked",
      "revisionNumber": null,
      "error": { "code": "policy", "message": "File type not allowed by bucket policy" },
      "expiresAt": "2026-10-09T14:22:31.482913",
      "createdAt": "2026-10-08T14:22:31.482913",
      "updatedAt": "2026-10-08T14:23:04.660271"
    }
  ]
}

Response fields

completedFilesinteger
Sessions that are ready.
failedFilesinteger
Sessions that are blocked or failed.
files[].objectAssetIduuid | null
The file's asset ID, set once the revision is created (from scanning on).
files[].revisionNumberinteger | null
The revision this upload created. 1 for a new file; higher when the upload landed on an existing key and added a revision to it.

Uploading to a key that already has a file does not create a second file. It adds a revision to the existing file, and the stable link starts serving the new bytes when the session is ready.

Stream upload events#

GET/api/events/stream
Requiresassets:read

Opens a server-sent events stream of upload state changes for the whole workspace. Use it instead of polling when you track many uploads or show live progress.

curl
curl -N "https://api.steadylink.io/api/events/stream" \
  -H "X-API-Key: $STEADYLINK_API_KEY"
Stream
event: upload.updated
data: {"type":"upload.updated","upload":{"id":"9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64","batchId":"b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13","status":"scanning","objectAssetId":"3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71","revisionNumber":1,"error":null}}

: keepalive

event: upload.updated
data: {"type":"upload.updated","upload":{"id":"9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64","batchId":"b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13","status":"ready","objectAssetId":"3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71","revisionNumber":1,"error":null}}

The upload object has the same fields as a session in Get an upload batch; the example above is shortened.

  • Every session in the workspace is included, from every user and integration. Filter by batchId or id.
  • Changes are checked about every 1.5 seconds. A connection starts with changes from the last few seconds, so it does not replay history; read the batch once after connecting to catch up.
  • A : keepalive comment arrives about every 15 seconds when nothing changes. If you see neither events nor keepalives for longer than that, reconnect.
  • The browser EventSource API cannot send an X-API-Key header. Consume the stream on your server, or use fetch with a streaming body.

Cancel an upload session#

DELETE/api/upload-sessions/{session_id}
Requiresassets:write

Cancels a session that has not finished, deletes any bytes already uploaded, and releases its storage reservation.

curl -X DELETE "https://api.steadylink.io/api/upload-sessions/2b8f6d14-9a3e-4c71-b0d5-e7f2a1c8b396" \
  -H "X-API-Key: $STEADYLINK_API_KEY"

The response is the session with status: "cancelled". Sessions that are already ready, blocked, or failed return 409 Completed uploads cannot be cancelled; to remove a finished file, delete it. Cancel sessions you abandon, such as when a user closes the page, so their storage reservation is released right away.

Limits#

LimitValue
Files per batch100
File sizeAt least 1 byte; at most the plan's upload limit, from 100 MiB on Free. See Plan access.
Bucket limitA bucket's max_upload_mb can lower the limit further. Checked during finalization.
Presigned URL lifetime15 minutes
Session lifetimeexpiresAt, by default 24 hours
StorageReserved at batch creation. Sessions that are cancelled, fail, or expire give it back.
RateEvery write under /api/upload-batches and /api/upload-sessions, including cancelling, counts as a write and as an expensive operation. The PUT to a presigned URL does not count.

Next steps#