Skip to content

TypeScript SDK

Every export in @steadylink/sdk 0.2.0, the official TypeScript client for uploads, replacements, links, revisions, collections, and workspace controls.

On this page

@steadylink/sdk is the official TypeScript client for the SteadyLink API. Use this page when you are writing server code, a build script, or a Next.js app and need the exact signature, options, and return shape of a method. Every method maps to one REST route (named under each heading), so anything you read here also holds for the REST API.

The package is ESM with bundled type definitions and no runtime dependencies. It runs anywhere standard fetch exists: Node.js 18 and later, Bun, Deno, edge runtimes, and browsers.

Install#

Terminal
npm install @steadylink/sdk

The package has three entry points:

Import pathContentsRuntime
@steadylink/sdkThe SteadyLink client, URL helpers, error classes, and types.Any
@steadylink/sdk/nodefileFromPath() for streaming files from disk.Node.js only
@steadylink/sdk/next-loaderA custom loader for next/image.Any

Create a client#

steadylink.ts
import { SteadyLink } from "@steadylink/sdk";

export const steadylink = new SteadyLink({
  apiKey: process.env.STEADYLINK_API_KEY!,
});

Create a key in Dashboard > Developers. Give it assets:read to read and assets:write to upload, replace, or delete. The platform helpers need workspace:read or workspace:admin; see API keys for every scope.

The constructor throws a TypeError when you pass both apiKey and accessToken, or neither, and when no fetch implementation is available.

Client options

apiKeystring
A workspace API key, sent as X-API-Key. Server-side only: never ship it in a browser bundle, mobile app, or repository. Use this or accessToken, not both.
accessTokenstring
A short-lived user access token, sent as Authorization: Bearer. Use this or apiKey.
workspaceIdstring
Sent as X-Workspace-Id. API keys already belong to one workspace, so you only need this with an accessToken for a user who belongs to several workspaces.
baseUrlstringDefault https://api.steadylink.io
API origin. Trailing slashes are removed.
cdnUrlstringDefault https://cdn.steadylink.io
Delivery origin used by link() and every url the SDK returns. Set it to a custom delivery domain to print links on that host.
appUrlstringDefault https://steadylink.io
Web app origin used to build collection portal URLs ({appUrl}/c/{slug}).
timeoutMsnumberDefault 30000
Timeout for each JSON API call, in milliseconds. 0 disables it. Byte uploads are not covered by this timeout.
uploadTimeoutMsnumberDefault 0
Timeout for each byte upload. 0 means no timeout, which is what you want for large files on slow links.
maxRetriesnumberDefault 2
How many times a retryable request is retried. See Retries.
fetchtypeof fetchDefault globalThis.fetch
A custom Fetch implementation, for tests or proxies. When set, browser upload progress falls back to stream counting instead of XMLHttpRequest.

The client exposes baseUrl, cdnUrl, and appUrl as read-only properties after normalization.

Uploads#

upload#

Uploads one or more files and returns one result per file, each with its stable link. This is the method to reach for in almost every case.

TypeScript
upload(bucketId: string, input: UploadSource | UploadSource[], options?: UploadOptions): Promise<UploadedFile[]>

Under the hood it splits the input into batches of 100 (POST /api/upload-batches), sends the bytes for up to concurrency files in parallel, completes each session with an idempotency key, and then polls until SteadyLink has finalized the files so each result carries its asset ID. A file that fails to transfer is cancelled so it does not hold storage.

UploadSource

filenamestringRequired
File name, including extension. The key becomes {folder}{path}{filename}.
dataUploadDataRequired
Blob, File, ArrayBuffer, typed array, ReadableStream, or string.
sizenumber
Byte length. Required only when data is a ReadableStream; otherwise it is measured. A stream without a size throws a TypeError before any request is made.
contentTypestring
Stored content type. Defaults to the Blob type, then a guess from the extension (see contentTypeFor), then application/octet-stream.
pathstring
Folder for this file, joined after options.folder. ".." segments throw.

UploadOptions

folderstringDefault bucket root
Folder prefix for every file, for example campaign/launch. Backslashes become slashes and a trailing slash is added.
visibility"public" | "private"
Set each finished file to this visibility. Omit it to inherit the bucket default. Setting it forces wait, because visibility can only be set on a finalized file.
waitboolean | WaitOptionsDefault true
Wait for finalization. false returns as soon as the bytes are sent, so assetId and url may be null. Pass { timeoutMs, intervalMs, signal } to tune polling (defaults: 300000 ms timeout, 750 ms first interval, growing 1.5 times per poll up to 3 s).
concurrencynumberDefault 4
Parallel byte uploads.
onProgress(progress) => void
Called with { filename, index, loaded, total } as bytes are sent. index is the position in the input array.
signalAbortSignal
Cancels in-flight transfers and polling. An abort rejects the whole call instead of being recorded as a per-file failure.
via"presigned" | "api"Default presigned
presigned sends bytes straight to object storage. api streams them through PUT /api/upload-sessions/{id}/content, for networks that block the storage host.
throwOnErrorbooleanDefault true
Throw SteadyLinkUploadError when any file fails. Set false to always get the full result array and inspect each status.

Each UploadedFile has this shape:

TypeScript
interface UploadedFile {
  uploadSessionId: string;
  batchId: string;
  bucketId: string;
  filename: string;
  key: string;              // "campaign/hero.webp"
  status: string;           // ready, committing, scanning, blocked, failed, or cancelled
  assetId: string | null;   // null until finalized, or when the file failed
  revisionNumber: number | null;
  url: string | null;       // https://cdn.steadylink.io/a/{assetId}
  visibility?: "public" | "private";
  error: { code: string; message: string } | null;
}
Example
import { fileFromPath } from "@steadylink/sdk/node";

const results = await steadylink.upload(bucketId, [
  await fileFromPath("./hero.webp"),
  { filename: "notes.txt", data: "Launch notes" },
], {
  folder: "launch/2026",
  visibility: "public",
  onProgress: ({ filename, loaded, total }) => console.log(filename, Math.round((loaded / total) * 100)),
});

for (const file of results) console.log(file.key, file.url);

Uploading to a key that already exists adds a new revision to that file instead of creating a second one, so the existing link starts serving the new bytes. Use replace when you mean to do that on purpose.

Byte uploads are never retried automatically. If a transfer fails, the result for that file has status: "failed" and an error, and the others continue.

uploadFile#

Creates a session, sends the bytes to the presigned URL, and completes the session for a single file. It does not wait for finalization, so the returned session may still be committing.

TypeScript
uploadFile(bucketId: string, file: UploadFileInput, signal?: AbortSignal): Promise<UploadSession>

UploadFileInput is { filename, size, contentType?, path?, data }, and size is always required. Prefer upload() for new code: it handles streams, progress, waiting, and failures.

createUploadBatch#

Creates up to 100 upload sessions in one call. Use it directly when the bytes come from somewhere else, such as a browser (see Browser usage).

TypeScript
createUploadBatch(bucketId: string, files: UploadInput[]): Promise<UploadBatch>

Route: POST /api/upload-batches. Each UploadInput is { filename, size, contentType?, path? }; contentType defaults to application/octet-stream and path to the bucket root. The batch's files array holds one UploadSession per input, in order, each with a short-lived uploadUrl.

TypeScript
interface UploadBatch { id: string; status: string; totalFiles: number; completedFiles?: number; failedFiles?: number; files: UploadSession[] }

interface UploadSession {
  id: string; batchId: string; bucketId: string; objectAssetId: string | null;
  filename: string; path: string; contentType: string; expectedSize: number;
  status: string; revisionNumber: number | null; error: { code: string; message: string } | null;
  expiresAt: string; createdAt: string; updatedAt: string; uploadUrl?: string;
}

getUploadBatch#

TypeScript
getUploadBatch(batchId: string): Promise<UploadBatch>

Route: GET /api/upload-batches/{batch_id}. Reads the aggregate status and every session. Sessions end in ready, blocked, failed, or cancelled.

sendUploadBytes#

Sends the bytes for one session, either to its presigned URL or through the API.

TypeScript
sendUploadBytes(
  session: { id: string; uploadUrl?: string; expectedSize?: number },
  data: UploadData,
  options?: { onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal; via?: "presigned" | "api" },
): Promise<void>

Presigned transfers always send Content-Type: application/octet-stream, because that is what the URLs are signed for; the stored type comes from the session. Streams are sent with an explicit Content-Length, so large files are never buffered. Throws SteadyLinkError with status 500 when the session has no uploadUrl and via is not api.

completeUpload#

TypeScript
completeUpload(uploadSessionId: string, idempotencyKey?: string): Promise<UploadSession>

Route: POST /api/upload-sessions/{id}/complete. Sends Idempotency-Key: complete-{uploadSessionId} unless you pass your own, which makes the call safe to retry. Completing a session that already reached a terminal state returns it unchanged; a different key for the same session returns 409.

cancelUpload#

TypeScript
cancelUpload(uploadSessionId: string): Promise<UploadSession>

Route: DELETE /api/upload-sessions/{id}. Deletes the temporary bytes and releases the reserved storage. Sessions that are already ready, blocked, or failed return 409.

waitForUploads#

Polls a batch until the listed sessions (or all of them) reach ready, blocked, failed, or cancelled.

TypeScript
waitForUploads(batchId: string, sessionIds?: string[], options?: WaitOptions): Promise<UploadBatch>

WaitOptions is { timeoutMs?: number; intervalMs?: number; signal?: AbortSignal }. Defaults: 300000 ms timeout and a 750 ms interval that grows by 1.5 times up to 3 s. Throws SteadyLinkTimeoutError when the deadline passes; the upload keeps processing on the server.

Replace files#

replace#

Publishes new bytes as the next revision of an existing file. The asset ID and every link to it stay the same, so pages, emails, and QR codes serve the new file without being edited.

TypeScript
replace(target: ReplaceTarget, source: UploadSource, options?: ReplaceOptions): Promise<ReplaceResult>

Parameters

targetstring | objectRequired
An asset ID ("3f2a9c1e-..."), { assetId }, or { bucketId, key }. The SDK method does not parse links; to accept a delivery URL, run it through parseAssetRef first.
sourceUploadSourceRequired
The new file. Same shape as for upload(). size is required for streams.
options.onProgress(loaded, total) => void
Byte progress.
options.signalAbortSignal
Cancels the transfer.

With an asset ID, the SDK first calls getFile() to find the bucket and key, and throws a 404 SteadyLinkError if the asset is not a bucket file. It then calls createReplacementUpload(), sends the bytes, and calls finishReplacement().

Example
const result = await steadylink.replace("3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d", await fileFromPath("./price-list-2026.pdf"));
// { replaced: true, version: 4, bucketId: "...", key: "docs/price-list.pdf", assetId: "3f2a9c1e-...", url: "https://cdn.steadylink.io/a/3f2a9c1e-..." }

ReplaceResult is { replaced, version, bucketId, key, assetId, url }. When you replace by bucket and key, the SDK looks the asset ID up afterwards; assetId and url are null if that lookup fails.

replaceFile#

Deprecated. Use replace({ bucketId, key }, file).

TypeScript
replaceFile(bucketId: string, key: string, file: { filename: string; size: number; contentType?: string; data: BodyInit }, signal?: AbortSignal): Promise<{ replaced: boolean; version: number }>

createReplacementUpload#

TypeScript
createReplacementUpload(bucketId: string, size: number, contentType?: string): Promise<{ uploadUrl: string; tempKey: string }>

Route: POST /api/assets/{bucket_id}/objects/upload-temp. Returns a presigned URL for the new bytes and a temporary key to pass to finishReplacement().

finishReplacement#

TypeScript
finishReplacement(bucketId: string, key: string, tempKey: string, originalFilename?: string): Promise<{ replaced: boolean; version: number }>

Route: POST /api/assets/{bucket_id}/objects/replace. Scans and commits the temporary bytes as the next revision of key. A file caught by the malware scan before commit is rejected with 400 and the current revision keeps serving. If the background scan finds malware after the new revision went current, the link returns 404 until you restore a clean revision. See The scan window after a replacement.

Builds the stable delivery URL for an asset. No request is made.

TypeScript
link(assetId: string, options?: TransformOptions): string

The URL is {cdnUrl}/a/{assetId}, plus query parameters for any transform options. Without version it follows the current revision; with version it is pinned to one revision. See assetUrl for every option.

TypeScript
steadylink.link(assetId);                                         // follows the current revision
steadylink.link(assetId, { width: 1200, height: 630, fit: "cover", format: "webp", quality: 80 });
steadylink.link(assetId, { version: 3 });                         // always revision 3

Creates a revocable, expiring link for a private file.

TypeScript
createSignedLink(assetId: string, options?: SignedLinkOptions): Promise<SignedLink>

SignedLinkOptions

ttlSecondsnumberDefault 300
Lifetime in seconds. The API clamps it between 60 and 2,592,000 (30 days).
namestring
Internal label shown in the grant list, up to 200 characters.
revisionnumber
Pin the grant to one revision. Omit to follow the current revision. A revision that does not exist returns 404.

Route: POST /api/assets/{asset_id}/signed-url. Returns { id, name, revisionNumber, expiresAt, token, url }, where url is link(assetId, { token }). The token is shown only in this response; store the grant id if you may need to revoke it.

TypeScript
listSignedLinks(assetId: string): Promise<{ items: SignedGrant[] }>

Route: GET /api/assets/{asset_id}/signed-urls. Up to 200 grants, newest first, each { id, name, revisionNumber, expiresAt, revokedAt, createdAt }. Tokens are not returned.

TypeScript
revokeSignedLink(assetId: string, grantId: string): Promise<unknown>

Route: DELETE /api/assets/{asset_id}/signed-urls/{grant_id}. The token stops working immediately.

Buckets#

listBuckets#

TypeScript
listBuckets(limit?: number, cursor?: string): Promise<{ items: Bucket[]; nextCursor: string | null }>

Route: GET /api/assets/. limit defaults to 100. Each Bucket has id, name, slug, and optionally kind, createdAt, isPrivate, and settings.

findBucket#

Finds a bucket by ID, slug, or name (case-insensitive) by paging through listBuckets(). Returns undefined when nothing matches. Checks at most 50 pages of 100.

TypeScript
findBucket(ref: string): Promise<Bucket | undefined>
TypeScript
const bucket = await steadylink.findBucket("marketing");
if (!bucket) throw new Error("No marketing bucket");

createBucket#

TypeScript
createBucket(name: string, slug?: string): Promise<{ id: string }>

Route: POST /api/assets/.

getBucket#

TypeScript
getBucket(bucketId: string): Promise<Bucket>

Route: GET /api/assets/{bucket_id}.

getAsset#

Deprecated. Use getBucket() for buckets and getFile() for files.

TypeScript
getAsset(assetId: string): Promise<Record<string, unknown>>

deleteBucket#

Deletes a bucket and everything in it. Every link to its files stops working. This cannot be undone.

TypeScript
deleteBucket(bucketId: string): Promise<unknown>

Files#

listFiles#

Lists the folders and files directly inside one folder (not recursive).

TypeScript
listFiles(bucketId: string, options?: { prefix?: string }): Promise<FileListing>

Route: GET /api/assets/{bucket_id}/objects. prefix is normalized to a/b/. Returns { prefix, folders, items, revision }, where folders is { name, path }[] and each item is a FileEntry:

TypeScript
interface FileEntry {
  id: string;                     // listing row ID
  objectAssetId: string | null;   // stable asset ID; null until finalization links the file
  key: string; name: string; size: number;
  lastModified: string | null; visibility: "public" | "private"; contentType: string | null;
  bucketId?: string; bucketName?: string; parent?: string | null;
}

listAllFiles#

Lists files across every bucket in the workspace, with cursor paging.

TypeScript
listAllFiles(options?: { limit?: number; cursor?: string }): Promise<{ items: FileEntry[]; nextCursor: string | null }>

Route: GET /api/assets/objects.

getFile#

Reads a file by its stable asset ID, including the bucket and key it lives at.

TypeScript
getFile(assetId: string): Promise<FileDetails>

Route: GET /api/assets/object/{asset_id}/stat. FileDetails is { bucketId?, key, id, objectAssetId, size, contentType, etag?, lastModified, metadata?, assetVersionId?, scanStatus? }.

statFile#

Reads a file by bucket and key. Same return shape as getFile().

TypeScript
statFile(bucketId: string, key: string): Promise<FileDetails>

Route: GET /api/assets/{bucket_id}/objects/stat.

setVisibility#

TypeScript
setVisibility(bucketId: string, key: string, visibility: "public" | "private" | "inherit"): Promise<{ updated: boolean }>

Route: POST /api/assets/{bucket_id}/objects/visibility. inherit removes the file-level setting so the bucket default applies. Private files need a signed link.

deleteFile#

Deletes a file, all of its revisions, and its cached transforms. Its link stops working.

TypeScript
deleteFile(bucketId: string, key: string): Promise<{ deleted: boolean }>

renameFile#

Moves a file to a new key in the same bucket. The asset ID, revisions, and link do not change.

TypeScript
renameFile(bucketId: string, fromKey: string, toKey: string): Promise<{ renamed: boolean }>

Returns 409 when toKey already exists.

createFolder#

TypeScript
createFolder(bucketId: string, path: string): Promise<unknown>

Route: POST /api/assets/{bucket_id}/folders. Creates an empty folder marker. You do not need it before uploading: uploading to a/b/file.png creates the folders implicitly.

Revisions#

listVersions#

TypeScript
listVersions(assetId: string): Promise<Revision[]>

Route: GET /api/assets/{asset_id}/versions. Retained revisions, newest first:

TypeScript
interface Revision {
  id: string; versionNumber: number; storageKey?: string;
  byteSize: number | null; width: number | null; height: number | null; mime: string | null; hash: string | null;
  label: string | null; note: string | null; createdBy?: string | null;
  isCurrent: boolean; scanStatus: string | null; metadataStatus: string | null; createdAt: string;
}

updateVersion#

Sets or clears a revision's label and note. Pass null to clear a field.

TypeScript
updateVersion(assetId: string, version: number, changes: { label?: string | null; note?: string | null }): Promise<{ id: string; versionNumber: number; label: string | null; note: string | null }>

promoteVersion#

Makes a retained revision current again. The link keeps working and now serves that revision.

TypeScript
promoteVersion(assetId: string, version: number): Promise<{ promoted: boolean; versionNumber: number; revisionId: string }>

Route: POST /api/assets/{asset_id}/versions/{version}/promote.

rollback#

Alias for promoteVersion().

TypeScript
rollback(assetId: string, version: number): Promise<{ promoted: boolean; versionNumber: number; revisionId: string }>

deleteVersion#

Deletes one revision's bytes and cached transforms and releases its storage.

TypeScript
deleteVersion(assetId: string, version: number, options?: { force?: boolean }): Promise<unknown>

Deleting the current revision returns 400 unless force: true. With force, the highest remaining revision becomes current; links pinned to the deleted revision stop working.

Collections#

Collections are ordered sets of files published as a portal at {appUrl}/c/{slug}. Every method that returns a collection adds a url field with that portal address.

TypeScript
interface CollectionInput {
  name: string;
  description?: string;
  accessMode?: "public" | "private" | "password" | "expiring";
  password?: string;     // for accessMode "password"
  expiresAt?: string;    // ISO 8601, for accessMode "expiring"
  noindex?: boolean;
}

A Collection adds id, slug, expiresAt, noindex, viewCount, downloadCount, itemCount, createdAt, updatedAt, url, and, when read individually, branding and items.

listCollections#

TypeScript
listCollections(): Promise<{ items: Collection[] }>

createCollection#

TypeScript
createCollection(input: CollectionInput): Promise<Collection>
TypeScript
const kit = await steadylink.createCollection({ name: "Press kit", accessMode: "public" });
console.log(kit.url); // https://steadylink.io/c/press-kit

getCollection#

TypeScript
getCollection(collectionId: string): Promise<Collection>

updateCollection#

TypeScript
updateCollection(collectionId: string, changes: Partial<CollectionInput>): Promise<Collection>

deleteCollection#

TypeScript
deleteCollection(collectionId: string): Promise<void>

Deletes the collection and its portal. The files themselves are not deleted.

addToCollection#

TypeScript
addToCollection(collectionId: string, assetId: string, version?: number): Promise<Record<string, unknown>>

Without version the item follows the file's current revision, so a replacement shows up in the portal. With version it stays on that revision.

removeFromCollection#

TypeScript
removeFromCollection(collectionId: string, itemId: string): Promise<void>

itemId is the collection item ID, not the asset ID.

pinCollectionItem#

TypeScript
pinCollectionItem(collectionId: string, itemId: string, version: number | null): Promise<Record<string, unknown>>

Pins an item to a revision, or pass null to follow the current revision again.

reorderCollection#

TypeScript
reorderCollection(collectionId: string, itemIds: string[]): Promise<Record<string, unknown>>

Route: PUT /api/collections/{collection_id}/items/order. Send every current item ID exactly once, in the new order.

Platform helpers#

These wrap the workspace-level routes under /api/platform. With an API key, reads need the workspace:read scope and writes need workspace:admin, including createMigration() and setFocalPoint(). With a user token, the user must be a workspace admin for everything except setFocalPoint() and the migration routes. Details are in the Platform API reference.

createMigration#

Creates a migration record and an upload batch of up to 100 sessions, keeping each file's path.

TypeScript
createMigration(bucketId: string, files: UploadInput[]): Promise<{ id: string; status: string; uploadBatch: UploadBatch }>

Send the bytes for each session in uploadBatch.files and complete them as with createUploadBatch(). When the batch finishes, a migration.completed webhook event fires.

listMigrations#

TypeScript
listMigrations(): Promise<{ items: Array<Record<string, unknown>> }>

The 50 most recent migrations, each { id, bucketId, uploadBatchId, status, totalFiles, createdAt }.

setFocalPoint#

Sets the default crop position for fit: "cover" transforms of an image.

TypeScript
setFocalPoint(assetId: string, x: number, y: number): Promise<{ x: number; y: number }>

x and y are fractions from 0 (left or top) to 1 (right or bottom), stored to four decimal places. Values outside that range return 400.

listDeliveryDomains#

TypeScript
listDeliveryDomains(): Promise<{ items: Array<Record<string, unknown>> }>

addDeliveryDomain#

TypeScript
addDeliveryDomain(hostname: string): Promise<Record<string, unknown>>

Registers a hostname such as media.example.com and returns the TXT record to create (_steadylink.{hostname}). Hostnames under steadylink.io and hostnames another workspace already claimed are rejected.

verifyDeliveryDomain#

TypeScript
verifyDeliveryDomain(domainId: string): Promise<Record<string, unknown>>

Checks the TXT record and marks the domain verified when it matches.

getDeliveryPolicy#

TypeScript
getDeliveryPolicy(): Promise<DeliveryPolicy>

Returns { mode, countries, storageRegion, availableStorageRegions }. mode is all, allow, or deny.

setDeliveryPolicy#

TypeScript
setDeliveryPolicy(policy: { mode: "all" | "allow" | "deny"; countries: string[]; storageRegion?: string }): Promise<DeliveryPolicy>

countries are two-letter ISO codes and must not be empty for allow or deny. storageRegion can only be the workspace's current region; any other value returns 422 unsupported_storage_region.

createWebhook#

TypeScript
createWebhook(url: string, events: WebhookEvent[]): Promise<Record<string, unknown>>

Registers a generic HTTPS receiver. The response includes the signing secret, shown only once. WebhookEvent is "asset.revision.published" | "asset.scan.completed" | "asset.delivery.threshold" | "migration.completed". The SDK always creates a generic destination; to register a Discord, Slack, or Teams destination, call request() with a destinationType (see Webhooks).

listWebhooks#

TypeScript
listWebhooks(): Promise<{ availableEvents: WebhookEvent[]; items: Array<Record<string, unknown>> }>

testWebhook#

Sends a test event to the endpoint right away and waits for its response.

TypeScript
testWebhook(webhookId: string): Promise<Record<string, unknown>>

The API returns { delivered: true, responseStatus, deliveryId } on a 2xx from your endpoint and 502 webhook_delivery_failed otherwise. The 0.2.0 type declares { queued: boolean }, which does not match; read the fields above.

configureSso#

Saves the workspace's OpenID Connect connection.

TypeScript
configureSso(configuration: { issuer: string; clientId: string; clientSecret?: string; emailDomains: string[]; enabled: boolean; enforce: boolean }): Promise<Record<string, unknown>>

clientSecret is required the first time. Enforcement is rejected with 409 sso_test_required until a test login through the saved connection succeeds, and SSO requires a Business or Enterprise plan. See Single sign-on.

Low-level calls#

request#

Sends an authenticated JSON request to any API route. Every other method is built on it, so it is the escape hatch for routes the SDK does not wrap yet.

TypeScript
request<T>(path: string, options?: RequestOptions): Promise<T>

path starts with /api/. RequestOptions is the standard RequestInit plus retry?: boolean (set false to disable retries for this call) and timeoutMs?: number (override the client timeout; 0 disables it). The SDK adds Accept: application/json, the credential header, X-Workspace-Id when configured, and Content-Type: application/json when there is a body. A 204 resolves to undefined; a non-JSON body resolves to its text.

TypeScript
const webhook = await steadylink.request<{ id: string; secret?: string }>("/api/platform/webhooks", {
  method: "POST",
  body: JSON.stringify({
    url: "https://hooks.slack.com/services/T000/B000/XXXX",
    destinationType: "slack",
    events: ["asset.revision.published"],
  }),
});

uploadToUrl#

Uploads bytes to any presigned storage URL without sending SteadyLink credentials.

TypeScript
uploadToUrl(uploadUrl: string, body: UploadData | BodyInit, contentType?: string, signal?: AbortSignal, options?: { size?: number; onProgress?: (loaded: number, total: number) => void }): Promise<void>

contentType defaults to application/octet-stream. Leave it at the default for SteadyLink upload URLs: they are signed for that type, and storage rejects any other value with a signature error. Pass options.size for streams. Storage error XML is translated into a SteadyLinkError message such as Storage rejected the upload (HTTP 403): SignatureDoesNotMatch.

Standalone exports#

These do not need a client, so they work in browser code without any credential.

assetUrl#

TypeScript
assetUrl(assetId: string, options?: TransformOptions, origin?: string): string

Builds {origin}/a/{assetId} with transform query parameters. origin defaults to https://cdn.steadylink.io. Throws a TypeError for an empty assetId.

TransformOptions

versionnumber
Query v. Pin to one retained revision. Pinned URLs never change content, so they can be cached for a long time.
widthnumber
Query w, in pixels. Rounded.
heightnumber
Query h, in pixels. Rounded.
format"webp" | "jpg" | "png"
Query fm.
qualitynumber
Query q. Clamped to 30 through 95.
fit"cover" | "contain" | "inside" | "outside"
Query fit.
focus"center" | "auto"
Query focus. auto picks high-detail framing when fit is cover.
focalPointobject
{ x, y }, each clamped to 0 through 1. Queries fp-x and fp-y. Use with fit: "cover".
tokenstring
Query token. A signed access token for a private file.

Transforms apply to raster images. See Image transformations for how each parameter behaves.

applyTransform#

TypeScript
applyTransform(params: URLSearchParams, options?: TransformOptions): URLSearchParams

Writes the transform options into an existing URLSearchParams, overwriting keys that are already there, and returns it.

parseAssetRef#

TypeScript
parseAssetRef(value: string): { assetId: string; params: URLSearchParams; origin?: string } | undefined

Extracts an asset ID from a bare ID, an /a/{id} path, or a full delivery URL. params holds the URL's query string and origin is set only for absolute URLs. Returns undefined for anything else.

TypeScript
parseAssetRef("https://media.example.com/a/3f2a9c1e-...?v=3");
// { assetId: "3f2a9c1e-...", params: URLSearchParams { v: "3" }, origin: "https://media.example.com" }

contentTypeFor#

TypeScript
contentTypeFor(filename: string): string

Guesses a MIME type from the extension (common image, audio, video, document, archive, and font types) and falls back to application/octet-stream.

joinFolder#

TypeScript
joinFolder(...parts: Array<string | undefined>): string

Joins folder segments into a/b/ form, or "" for the bucket root. Backslashes become slashes, empty and . segments are dropped, and .. throws a TypeError.

Constants#

DEFAULT_API_URL (https://api.steadylink.io), DEFAULT_CDN_URL (https://cdn.steadylink.io), and DEFAULT_APP_URL (https://steadylink.io).

Types#

Every interface on this page is exported as a type, including SteadyLinkClientOptions, RequestOptions, Bucket, FileEntry, FileListing, FileDetails, UploadSource, UploadOptions, UploadedFile, UploadBatch, UploadSession, ReplaceTarget, ReplaceResult, Revision, SignedLinkOptions, SignedLink, Collection, CollectionInput, DeliveryPolicy, WebhookEvent, TransformOptions, ImageFit, ImageFormat, UploadData, and ByteProgress.

Node.js helpers#

fileFromPath#

Reads a file from disk as an upload source. The stream is opened lazily when the upload starts, so multi-gigabyte files never sit in memory and unused sources never hold a file descriptor.

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

fileFromPath(path: string, options?: { filename?: string; contentType?: string; path?: string }): Promise<UploadSource>

filename defaults to the base name, contentType to a guess from that name, and options.path sets the destination folder. Throws a TypeError when path is not a regular file.

TypeScript
const [video] = await steadylink.upload(bucketId, await fileFromPath("./keynote.mp4"), {
  onProgress: ({ loaded, total }) => process.stdout.write(`\r${((loaded / total) * 100).toFixed(1)}%`),
});

next/image loader#

@steadylink/sdk/next-loader turns an asset ID into a resized delivery URL for each srcset width, so you can keep the asset ID in your CMS and replace the image at any time.

steadylink-loader.js
export { default } from "@steadylink/sdk/next-loader";
next.config.js
module.exports = {
  images: { loader: "custom", loaderFile: "./steadylink-loader.js" },
};
page.tsx
<Image src="3f2a9c1e-..." alt="Launch hero" width={1200} height={630} sizes="100vw" />
// https://cdn.steadylink.io/a/3f2a9c1e-...?w=1200&fm=webp&q=75

default export#

The default loader. src can be an asset ID, an /a/{id} path, or a full delivery URL; a URL keeps its host and query (such as v=3). Any other src, such as /logo.png, is returned unchanged, so local images keep working. The origin is the URL's own origin, then NEXT_PUBLIC_STEADYLINK_CDN_URL, then https://cdn.steadylink.io.

createSteadyLinkLoader#

TypeScript
createSteadyLinkLoader(options?: SteadyLinkLoaderOptions): (props: { src: string; width: number; quality?: number }) => string

SteadyLinkLoaderOptions

originstring
Delivery origin, for example a custom domain. Defaults as described above.
format"webp" | "jpg" | "png" | falseDefault webp
Output format. false keeps the source format. An fm already in the src URL wins.
qualitynumberDefault 75
Used when next/image does not pass a quality.
fitImageFit
Added unless the src URL already has fit.

The types ImageLoaderProps and SteadyLinkLoaderOptions are exported from the same path. The Next.js images guide walks through a full setup.

Errors#

Every failure is one of four classes, all exported from @steadylink/sdk.

SteadyLinkError#

A non-success HTTP response from the API or from a presigned storage URL.

Fields

statusnumber
HTTP status.
messagestring
The response's message when it is a string, such as Upload session expired. When the API returns a structured error, the message is SteadyLink request failed with HTTP {status}.
codestring | undefined
A string code read from detail.code or code. API error bodies carry the numeric status in code, so for API errors this is undefined in 0.2.0; read the machine-readable code from detail as shown below. Storage errors from presigned uploads do set it, for example SignatureDoesNotMatch.
detailunknown
The parsed response body. For API errors this is the full error body, { code, message, requestId }, where message is either a string or an object such as { code: "insufficient_scope", required: "assets:write" }.
requestIdstring | undefined
From the X-Request-Id header or the body's requestId. Include it when you contact support.
retryAfterMsnumber | undefined
From Retry-After, in milliseconds.

To branch on the API's error code, read it from the body:

TypeScript
function apiErrorCode(error: SteadyLinkError): string | undefined {
  if (error.code) return error.code;
  const message = (error.detail as { message?: unknown } | undefined)?.message;
  return message && typeof message === "object" && "code" in message ? String(message.code) : undefined;
}

// apiErrorCode(error) === "insufficient_scope", "api_write_limit_exceeded", "storage_quota_exceeded", ...

SteadyLinkNetworkError#

No HTTP response arrived: DNS, TLS, connection reset, timeout, or cancellation through an AbortSignal. The original error is on cause.

SteadyLinkTimeoutError#

waitForUploads() (or upload() while waiting) hit its deadline. The upload is still processing on the server; poll getUploadBatch() later.

SteadyLinkUploadError#

One or more files in an upload() call failed and throwOnError was not false. results holds every file's UploadedFile, so you can tell which succeeded.

TypeScript
import { SteadyLinkError, SteadyLinkNetworkError, SteadyLinkUploadError, type UploadedFile } from "@steadylink/sdk";

try {
  await steadylink.upload(bucketId, files);
} catch (error) {
  if (error instanceof SteadyLinkUploadError) {
    for (const file of error.results as UploadedFile[]) if (file.error) console.error(file.key, file.error.message);
  } else if (error instanceof SteadyLinkError) {
    console.error(error.status, apiErrorCode(error), error.requestId);
  } else if (error instanceof SteadyLinkNetworkError) {
    console.error(error.message, error.cause);
  }
}

The status codes and error codes themselves are listed on Errors.

Retries#

JSON calls are retried up to maxRetries times (2 by default) when both of these hold:

  • The method is GET, HEAD, or OPTIONS, or the request carries an Idempotency-Key header. completeUpload() always sends one.
  • The request failed at the network level, or the response was 429, 502, 503, or 504.

The wait is the Retry-After value when the response has one, otherwise 250 ms, 500 ms, 1 s, and so on. Other writes, such as replace() or deleteFile(), are never retried automatically, because repeating them could apply the change twice. Byte uploads to storage are never retried either. Cancelling through an AbortSignal stops retries.

Browser usage#

Do not put an API key in browser code. Anyone can read it from the bundle, and it carries the full scopes of the key. Instead, split the upload between your server and the browser:

  1. Server: create the session

    TypeScript
    const batch = await steadylink.createUploadBatch(bucketId, [{ filename, size, contentType }]);
    return Response.json({ id: batch.files[0].id, uploadUrl: batch.files[0].uploadUrl });
  2. Browser: send the bytes

    TypeScript
    await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: file });

    The presigned URL is temporary and only accepts this one upload. It never carries SteadyLink credentials.

  3. Server: complete it

    TypeScript
    await steadylink.completeUpload(sessionId);

When the SDK itself runs in a browser with a short-lived accessToken, upload() and replace() with onProgress use XMLHttpRequest so you get real upload progress events. Building links with assetUrl() needs no credential at all. For a drop-in UI, see the upload widget.

Next steps#