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
- Install
- Create a client
- Uploads
- upload
- uploadFile
- createUploadBatch
- getUploadBatch
- sendUploadBytes
- completeUpload
- cancelUpload
- waitForUploads
- Replace files
- replace
- replaceFile
- createReplacementUpload
- finishReplacement
- Links and signed links
- link
- createSignedLink
- listSignedLinks
- revokeSignedLink
- Buckets
- listBuckets
- findBucket
- createBucket
- getBucket
- getAsset
- deleteBucket
- Files
- listFiles
- listAllFiles
- getFile
- statFile
- setVisibility
- deleteFile
- renameFile
- createFolder
- Revisions
- listVersions
- updateVersion
- promoteVersion
- rollback
- deleteVersion
- Collections
- listCollections
- createCollection
- getCollection
- updateCollection
- deleteCollection
- addToCollection
- removeFromCollection
- pinCollectionItem
- reorderCollection
- Platform helpers
- createMigration
- listMigrations
- setFocalPoint
- listDeliveryDomains
- addDeliveryDomain
- verifyDeliveryDomain
- getDeliveryPolicy
- setDeliveryPolicy
- createWebhook
- listWebhooks
- testWebhook
- configureSso
- Low-level calls
- request
- uploadToUrl
- Standalone exports
- assetUrl
- applyTransform
- parseAssetRef
- contentTypeFor
- joinFolder
- Constants
- Types
- Node.js helpers
- fileFromPath
- next/image loader
- default export
- createSteadyLinkLoader
- Errors
- SteadyLinkError
- SteadyLinkNetworkError
- SteadyLinkTimeoutError
- SteadyLinkUploadError
- Retries
- Browser usage
- Next steps
@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#
npm install @steadylink/sdkThe package has three entry points:
| Import path | Contents | Runtime |
|---|---|---|
@steadylink/sdk | The SteadyLink client, URL helpers, error classes, and types. | Any |
@steadylink/sdk/node | fileFromPath() for streaming files from disk. | Node.js only |
@steadylink/sdk/next-loader | A custom loader for next/image. | Any |
Create a client#
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 oraccessToken, not both. accessTokenstring- A short-lived user access token, sent as
Authorization: Bearer. Use this orapiKey. workspaceIdstring- Sent as
X-Workspace-Id. API keys already belong to one workspace, so you only need this with anaccessTokenfor a user who belongs to several workspaces. baseUrlstringDefaulthttps://api.steadylink.io- API origin. Trailing slashes are removed.
cdnUrlstringDefaulthttps://cdn.steadylink.io- Delivery origin used by
link()and everyurlthe SDK returns. Set it to a custom delivery domain to print links on that host. appUrlstringDefaulthttps://steadylink.io- Web app origin used to build collection portal URLs (
{appUrl}/c/{slug}). timeoutMsnumberDefault30000- Timeout for each JSON API call, in milliseconds.
0disables it. Byte uploads are not covered by this timeout. uploadTimeoutMsnumberDefault0- Timeout for each byte upload.
0means no timeout, which is what you want for large files on slow links. maxRetriesnumberDefault2- How many times a retryable request is retried. See Retries.
fetchtypeof fetchDefaultglobalThis.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.
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}. dataUploadDataRequiredBlob,File,ArrayBuffer, typed array,ReadableStream, orstring.sizenumber- Byte length. Required only when
datais aReadableStream; otherwise it is measured. A stream without a size throws aTypeErrorbefore any request is made. contentTypestring- Stored content type. Defaults to the
Blobtype, then a guess from the extension (see contentTypeFor), thenapplication/octet-stream. pathstring- Folder for this file, joined after
options.folder.".."segments throw.
UploadOptions
folderstringDefaultbucket 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 | WaitOptionsDefaulttrue- Wait for finalization.
falsereturns as soon as the bytes are sent, soassetIdandurlmay benull. 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). concurrencynumberDefault4- Parallel byte uploads.
onProgress(progress) => void- Called with
{ filename, index, loaded, total }as bytes are sent.indexis 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"Defaultpresignedpresignedsends bytes straight to object storage.apistreams them throughPUT /api/upload-sessions/{id}/content, for networks that block the storage host.throwOnErrorbooleanDefaulttrue- Throw
SteadyLinkUploadErrorwhen any file fails. Setfalseto always get the full result array and inspect eachstatus.
Each UploadedFile has this shape:
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;
}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.
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).
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.
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#
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.
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#
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#
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.
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.
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().sizeis 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().
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).
replaceFile(bucketId: string, key: string, file: { filename: string; size: number; contentType?: string; data: BodyInit }, signal?: AbortSignal): Promise<{ replaced: boolean; version: number }>createReplacementUpload#
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#
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.
Links and signed links#
link#
Builds the stable delivery URL for an asset. No request is made.
link(assetId: string, options?: TransformOptions): stringThe 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.
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 3createSignedLink#
Creates a revocable, expiring link for a private file.
createSignedLink(assetId: string, options?: SignedLinkOptions): Promise<SignedLink>SignedLinkOptions
ttlSecondsnumberDefault300- 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.
listSignedLinks#
listSignedLinks(assetId: string): Promise<{ items: SignedGrant[] }>Route: GET /api/assets/{asset_id}/signed-urls. Up to 200 grants, newest first, each { id, name, revisionNumber, expiresAt, revokedAt, createdAt }. Tokens are not returned.
revokeSignedLink#
revokeSignedLink(assetId: string, grantId: string): Promise<unknown>Route: DELETE /api/assets/{asset_id}/signed-urls/{grant_id}. The token stops working immediately.
Buckets#
listBuckets#
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.
findBucket(ref: string): Promise<Bucket | undefined>const bucket = await steadylink.findBucket("marketing");
if (!bucket) throw new Error("No marketing bucket");createBucket#
createBucket(name: string, slug?: string): Promise<{ id: string }>Route: POST /api/assets/.
getBucket#
getBucket(bucketId: string): Promise<Bucket>Route: GET /api/assets/{bucket_id}.
getAsset#
Deprecated. Use getBucket() for buckets and getFile() for files.
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.
deleteBucket(bucketId: string): Promise<unknown>Files#
listFiles#
Lists the folders and files directly inside one folder (not recursive).
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:
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.
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.
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().
statFile(bucketId: string, key: string): Promise<FileDetails>Route: GET /api/assets/{bucket_id}/objects/stat.
setVisibility#
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.
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.
renameFile(bucketId: string, fromKey: string, toKey: string): Promise<{ renamed: boolean }>Returns 409 when toKey already exists.
createFolder#
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#
listVersions(assetId: string): Promise<Revision[]>Route: GET /api/assets/{asset_id}/versions. Retained revisions, newest first:
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.
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.
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().
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.
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.
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#
listCollections(): Promise<{ items: Collection[] }>createCollection#
createCollection(input: CollectionInput): Promise<Collection>const kit = await steadylink.createCollection({ name: "Press kit", accessMode: "public" });
console.log(kit.url); // https://steadylink.io/c/press-kitgetCollection#
getCollection(collectionId: string): Promise<Collection>updateCollection#
updateCollection(collectionId: string, changes: Partial<CollectionInput>): Promise<Collection>deleteCollection#
deleteCollection(collectionId: string): Promise<void>Deletes the collection and its portal. The files themselves are not deleted.
addToCollection#
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#
removeFromCollection(collectionId: string, itemId: string): Promise<void>itemId is the collection item ID, not the asset ID.
pinCollectionItem#
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#
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.
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#
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.
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#
listDeliveryDomains(): Promise<{ items: Array<Record<string, unknown>> }>addDeliveryDomain#
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#
verifyDeliveryDomain(domainId: string): Promise<Record<string, unknown>>Checks the TXT record and marks the domain verified when it matches.
getDeliveryPolicy#
getDeliveryPolicy(): Promise<DeliveryPolicy>Returns { mode, countries, storageRegion, availableStorageRegions }. mode is all, allow, or deny.
setDeliveryPolicy#
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#
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#
listWebhooks(): Promise<{ availableEvents: WebhookEvent[]; items: Array<Record<string, unknown>> }>testWebhook#
Sends a test event to the endpoint right away and waits for its response.
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.
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.
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.
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.
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#
assetUrl(assetId: string, options?: TransformOptions, origin?: string): stringBuilds {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.autopicks high-detail framing whenfitiscover. focalPointobject{ x, y }, each clamped to 0 through 1. Queriesfp-xandfp-y. Use withfit: "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#
applyTransform(params: URLSearchParams, options?: TransformOptions): URLSearchParamsWrites the transform options into an existing URLSearchParams, overwriting keys that are already there, and returns it.
parseAssetRef#
parseAssetRef(value: string): { assetId: string; params: URLSearchParams; origin?: string } | undefinedExtracts 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.
parseAssetRef("https://media.example.com/a/3f2a9c1e-...?v=3");
// { assetId: "3f2a9c1e-...", params: URLSearchParams { v: "3" }, origin: "https://media.example.com" }contentTypeFor#
contentTypeFor(filename: string): stringGuesses a MIME type from the extension (common image, audio, video, document, archive, and font types) and falls back to application/octet-stream.
joinFolder#
joinFolder(...parts: Array<string | undefined>): stringJoins 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.
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.
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.
export { default } from "@steadylink/sdk/next-loader";module.exports = {
images: { loader: "custom", loaderFile: "./steadylink-loader.js" },
};<Image src="3f2a9c1e-..." alt="Launch hero" width={1200} height={630} sizes="100vw" />
// https://cdn.steadylink.io/a/3f2a9c1e-...?w=1200&fm=webp&q=75default 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#
createSteadyLinkLoader(options?: SteadyLinkLoaderOptions): (props: { src: string; width: number; quality?: number }) => stringSteadyLinkLoaderOptions
originstring- Delivery origin, for example a custom domain. Defaults as described above.
format"webp" | "jpg" | "png" | falseDefaultwebp- Output format.
falsekeeps the source format. Anfmalready in thesrcURL wins. qualitynumberDefault75- Used when
next/imagedoes not pass a quality. fitImageFit- Added unless the
srcURL already hasfit.
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
messagewhen it is a string, such asUpload session expired. When the API returns a structured error, the message isSteadyLink request failed with HTTP {status}. codestring | undefined- A string code read from
detail.codeorcode. API error bodies carry the numeric status incode, so for API errors this isundefinedin 0.2.0; read the machine-readable code fromdetailas shown below. Storage errors from presigned uploads do set it, for exampleSignatureDoesNotMatch. detailunknown- The parsed response body. For API errors this is the full error body,
{ code, message, requestId }, wheremessageis either a string or an object such as{ code: "insufficient_scope", required: "assets:write" }. requestIdstring | undefined- From the
X-Request-Idheader or the body'srequestId. 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:
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.
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, orOPTIONS, or the request carries anIdempotency-Keyheader.completeUpload()always sends one. - The request failed at the network level, or the response was
429,502,503, or504.
The wait is the Retry-After value when the response has one, otherwise 250 ms, 500 ms, 1 s, and so on. Other writes, such as replace() or deleteFile(), are never retried automatically, because repeating them could apply the change twice. Byte uploads to storage are never retried either. Cancelling through an AbortSignal stops retries.
Browser usage#
Do not put an API key in browser code. Anyone can read it from the bundle, and it carries the full scopes of the key. Instead, split the upload between your server and the browser:
Server: create the session
TypeScript const batch = await steadylink.createUploadBatch(bucketId, [{ filename, size, contentType }]); return Response.json({ id: batch.files[0].id, uploadUrl: batch.files[0].uploadUrl });Browser: send the bytes
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.
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.