Widgets and collections API
Manage origin-restricted upload widgets and their submissions, and publish ordered collections as branded file portals.
On this page
- Access model
- Upload widgets
- Create a widget
- List widgets
- Update a widget
- Delete a widget
- List widget submissions
- Download a widget submission
- Approve a widget submission
- Reject a widget submission
- Public: initialize an upload
- Public: complete an upload
- Collections
- The collection object
- List collections
- Create a collection
- Get a collection
- Update a collection
- Delete a collection
- Add an item
- Reorder items
- Pin or unpin an item
- Remove an item
- Get portal analytics
- Public: exchange a password for an access token
- Public: read a portal
- Public: preview an item
- Public: download an item
- Next steps
This page covers two ways to share with people outside your workspace. Upload widgets let visitors to your own website send you files, restricted to the origins you list. Collections gather files into an ordered set and publish it as a portal at https://steadylink.io/c/{slug}. Both follow the stable-link model: a widget can replace a file in place, and a collection item can follow the current revision or stay pinned to one.
Access model#
- Management routes accept a signed-in session or an API key. With a key,
GETroutes needassets:readand every other method needsassets:write. That includes portal analytics, which needsassets:read, notanalytics:read. - Public routes under
/api/public/need no credentials. Widgets are protected by the browserOriginand a one-time session token; portals by their access mode. - Plan limits. Active widgets: 1 on Hobby, 3 on Personal, 15 on Pro, 100 on Business. Collections: 3, 15, 100, and 500. Going over returns
409with"code": "feature_limit_reached".
Upload widgets#
A widget is a configuration plus a public key (wdg_...) that you embed in your site. In create mode each upload becomes a new file in a bucket. In replace mode each upload becomes a new revision of one file, so its stable link serves the new bytes. With "approvalMode": "review", nothing is published until someone approves it.
Create a widget#
/api/widgetsassets:writeBody
namestringRequired- Internal name, up to 200 characters.
allowedOriginsstring[]Required- From 1 to 30 origins allowed to use the widget, as scheme and host only:
https://www.northwind.example. A value can also hold several origins separated by commas or spaces. Paths, queries, and credentials are rejected. modestringDefaultcreatecreateorreplace.destinationBucketIduuid- Required in
createmode. Bucket for new files. assetIduuid- Required in
replacemode. The file that uploads replace. collectionIduuid- Add each published file to this collection.
acceptedTypesstring[]- Up to 30 rules: MIME types (
application/pdf), wildcards (image/*), or extensions (.pdf). Empty accepts any type. maxBytesintegerDefault26214400- Per-file limit, up to 2 GB and no more than the plan's upload limit (
413otherwise). buttonTextstringDefaultChoose a file- Up to 80 characters.
themestringDefaultlightlight,dark, orauto.approvalModestringDefaultreviewreviewholds uploads for approval.autopublishes them immediately.allowAutoPublishbooleanDefaultfalse- Must be
truewhenapprovalModeisauto. It is a deliberate acknowledgment that anyone on an allowed origin can publish. activebooleanDefaulttrue- Inactive widgets refuse uploads and do not count toward the plan limit.
curl -X POST https://api.steadylink.io/api/widgets \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Campaign intake",
"mode": "create",
"destinationBucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10",
"acceptedTypes": ["image/*", ".pdf"],
"maxBytes": 26214400,
"allowedOrigins": ["https://www.northwind.example", "https://staging.northwind.example"],
"approvalMode": "review"
}'{
"id": "2e7a9c40-5b1d-4f83-a6e2-9c0d8b3f1e57",
"name": "Campaign intake",
"publicKey": "wdg_8Hk2...",
"mode": "create",
"destinationBucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10",
"assetId": null,
"collectionId": null,
"acceptedTypes": ["image/*", ".pdf"],
"maxBytes": 26214400,
"buttonText": "Choose a file",
"theme": "light",
"allowedOrigins": ["https://www.northwind.example", "https://staging.northwind.example"],
"approvalMode": "review",
"active": true,
"createdAt": "2026-10-08T12:00:00.000000",
"updatedAt": "2026-10-08T12:00:00.000000"
}Validation errors, such as auto without allowAutoPublish or create without a bucket, return 422. To embed the widget, see the upload widget guide.
List widgets#
/api/widgetsassets:readReturns every widget in the workspace, newest first, including publicKey. The public key is not a secret; the origin list is what restricts it.
Update a widget#
/api/widgets/{widget_id}assets:writeReplaces the whole configuration. Despite the method, send every field as you would for create; omitted fields go back to their defaults. The public key does not change. Reactivating a widget counts toward the plan limit.
Delete a widget#
/api/widgets/{widget_id}assets:writeDeletes the widget and any uploads still waiting for approval. Returns 204 No Content. Pages that embed it stop working.
List widget submissions#
/api/widget-submissionsassets:readReturns the 200 most recent uploads that reached a decision point, newest first.
{
"items": [
{
"id": "d6c1f093-8e2a-4b75-9f40-3a7e5b2d1c86",
"widgetId": "2e7a9c40-5b1d-4f83-a6e2-9c0d8b3f1e57",
"widgetName": "Campaign intake",
"mode": "create",
"filename": "storefront.jpg",
"contentType": "image/jpeg",
"byteSize": 1820443,
"origin": "https://www.northwind.example",
"status": "awaiting_approval",
"resultAssetId": null,
"createdAt": "2026-10-08T12:41:09.000000",
"expiresAt": "2026-10-15T12:41:20.000000"
}
]
}status is awaiting_approval, approved, rejected, failed, or expired. A submission waits seven days for a decision, then can no longer be approved.
Download a widget submission#
/api/widget-submissions/{submission_id}/downloadassets:readReturns url (download) and previewUrl (inline), both valid for 5 minutes, plus filename and contentType. Only works while the submission is awaiting_approval; otherwise 404.
Approve a widget submission#
/api/widget-submissions/{submission_id}/approveassets:writeIn create mode, commits the upload as a new file in the widget-submissions/ folder of the destination bucket. In replace mode, adds it as a new revision of the target file. If the widget has a collectionId, the file is appended to that collection.
{ "status": "approved", "assetId": "7c3e9f10-2b5d-4a86-9e1f-0d4c8b2a6e53" }Errors: 409 if it is not awaiting approval or the destination no longer exists, 410 once it has expired, 422 if the file fails validation. A failed approval leaves the submission failed.
Reject a widget submission#
/api/widget-submissions/{submission_id}/rejectassets:writeDeletes the uploaded bytes and returns { "status": "rejected" }.
Public: initialize an upload#
/api/public/widgets/{public_key}/initializeCalled from the browser on an allowed origin. SteadyLink checks the Origin header, the file's type and size, and the workspace's temporary storage, then returns a presigned upload URL valid for ten minutes.
Body
filenamestringRequired- Up to 512 characters.
sizeintegerRequired- Exact size in bytes.
contentTypestringDefaultapplication/octet-stream- The file's MIME type, checked against
acceptedTypes.
{
"sessionToken": "Wc0q...",
"uploadUrl": "<presigned PUT URL>",
"expiresAt": "2026-10-08T12:51:09.000000",
"permission": "create"
}PUT the bytes to uploadUrl with Content-Type: application/octet-stream. Errors: 403 for an inactive widget or an origin not on the list, 413 above the smaller of the widget's maxBytes and the plan's upload limit, 415 for a type that is not accepted.
Public: complete an upload#
/api/public/widgets/{public_key}/completeSend the sessionToken as Authorization: Bearer <sessionToken> from the same origin that initialized the upload. The token works once.
- With
review, returns{ "status": "awaiting_approval", "assetId": null, "replacementRequestId": null }and notifies the workspace. - With
auto, publishes immediately and returns{ "status": "published", "assetId": "...", "replacementRequestId": null }. New files go to the root of the destination bucket.
Errors: 409 if the session expired or was already used, 403 if the origin differs, 400 if the uploaded size does not match, 422 if the file fails validation.
CORS preflight requests to /api/public/widgets/ are answered for any origin, but every actual request is checked against allowedOrigins, and the response allows only the validated origin.
Collections#
A collection is an ordered list of files with an access mode. Each item either follows the file's current revision or is pinned to one retained revision. Items whose revision has not passed the malware scan are left out of the portal.
accessMode | Who can open the portal |
|---|---|
private | Nobody. Public routes return 404. Use it for drafts. |
public | Anyone with the link. |
password | Anyone who enters the password. |
expiring | Anyone with the link until expiresAt. |
Any collection with an expiresAt in the past returns 410 from the public routes, whatever its mode.
The collection object#
{
"id": "c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48",
"name": "Spring press kit",
"description": "Logos, product shots, and the fact sheet.",
"slug": "spring-press-kit-4f9a1c",
"accessMode": "public",
"expiresAt": null,
"noindex": true,
"viewCount": 312,
"downloadCount": 87,
"itemCount": 12,
"createdAt": "2026-09-30T09:00:00.000000",
"updatedAt": "2026-10-08T12:10:00.000000",
"canCustomizeBranding": true,
"branding": {
"name": "Northwind",
"logoUrl": "https://www.northwind.example/logo.svg",
"url": "https://www.northwind.example",
"primaryColor": "#0b0e0c",
"accentColor": "#b8ff5a",
"removeSteadyLinkBranding": false
}
}The portal URL is https://steadylink.io/c/ followed by slug. The JavaScript SDK adds it to every collection as url. itemCount is null in the response to an update.
List collections#
/api/collectionsassets:readcurl https://api.steadylink.io/api/collections \
-H "X-API-Key: $STEADYLINK_API_KEY"const { items } = await steadylink.listCollections();Returns { "items": [...] } with every collection, most recently updated first.
Create a collection#
/api/collectionsassets:writeBody
namestringRequired- Up to 200 characters. Also used to build the slug, with a random suffix.
descriptionstring- Up to 2000 characters.
accessModestringDefaultprivateprivate,public,password, orexpiring.passwordstring- 8 to 128 characters. Required for
password. Stored only as a hash. expiresAtdatetime- Required and in the future for
expiring. Optional for other modes. noindexbooleanDefaulttrue- Ask search engines not to index the portal.
brandNamestring- Portal branding. Branding fields require Pro, Business, or Enterprise; otherwise the request fails with
403. brandLogoUrlstring- HTTPS URL of a logo.
brandUrlstring- HTTPS link for the brand name.
primaryColorstring- Six-digit hex, such as
#0b0e0c. accentColorstring- Six-digit hex.
removeSteadyLinkBrandingbooleanDefaultfalse- Hide SteadyLink branding on the portal.
curl -X POST https://api.steadylink.io/api/collections \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Spring press kit", "accessMode": "password", "password": "tulip-harbor-91" }'const collection = await steadylink.createCollection({
name: "Spring press kit",
accessMode: "password",
password: "tulip-harbor-91",
});
console.log(collection.url);Returns the collection object with status 201 Created.
Get a collection#
/api/collections/{collection_id}assets:readcurl https://api.steadylink.io/api/collections/c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48 \
-H "X-API-Key: $STEADYLINK_API_KEY"const collection = await steadylink.getCollection("c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48");Returns the collection object plus its items in portal order:
{
"id": "c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48",
"name": "Spring press kit",
"...": "other collection fields",
"items": [
{
"id": "61f0b8d3-a2c7-4e95-8b14-7d3e0a9c2f56",
"assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44",
"name": "logo-primary.svg",
"position": 0,
"version": 3,
"pinned": false,
"mime": "image/svg+xml",
"byteSize": 18244,
"width": null,
"height": null,
"createdAt": "2026-09-30T09:02:00.000000",
"previewUrl": "/api/public/collections/spring-press-kit-4f9a1c/items/61f0b8d3-a2c7-4e95-8b14-7d3e0a9c2f56/preview"
}
]
}version is the revision the portal serves now: the pinned one, or the current one when pinned is false.
Update a collection#
/api/collections/{collection_id}assets:writeSend only the fields to change; they are the same as for create. Switching to password requires a password unless one is already set, and switching to expiring requires an expiresAt unless one is already set (422 otherwise). Send "expiresAt": null to remove an expiration.
curl -X PATCH https://api.steadylink.io/api/collections/c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48 \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "accessMode": "public" }'await steadylink.updateCollection("c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48", { accessMode: "public" });Delete a collection#
/api/collections/{collection_id}assets:writeDeletes the collection and its items, not the files. Returns 204 No Content. The portal link stops working.
Add an item#
/api/collections/{collection_id}/itemsassets:writeBody
assetIduuidRequired- A file in the workspace.
versioninteger- Pin this revision. Omit to follow the current revision, so replacements show up in the portal automatically.
curl -X POST https://api.steadylink.io/api/collections/c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48/items \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44", "version": 12 }'await steadylink.addToCollection("c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48", "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44", 12);{ "id": "61f0b8d3-a2c7-4e95-8b14-7d3e0a9c2f56", "assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44", "position": 4, "pinnedVersion": 12 }New items go to the end. Errors: 409 if the file is already in the collection, 422 if the file or revision does not exist.
Reorder items#
/api/collections/{collection_id}/items/orderassets:writeSend itemIds with every item ID in the collection exactly once, in the new order. A missing, extra, or repeated ID returns 422, which protects you from overwriting a change someone else just made. Returns { "updated": true }.
curl -X PUT https://api.steadylink.io/api/collections/c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48/items/order \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "itemIds": ["61f0b8d3-a2c7-4e95-8b14-7d3e0a9c2f56", "0b9d4e7a-5c21-4f38-a6e0-2d8b1f7c3e94"] }'await steadylink.reorderCollection(collectionId, [firstItemId, secondItemId]);Pin or unpin an item#
/api/collections/{collection_id}/items/{item_id}assets:writeSend { "version": 12 } to pin a revision, or { "version": null } to follow the current revision again. Returns { "updated": true, "pinnedVersion": 12 }, or 422 if the revision does not exist.
curl -X PATCH https://api.steadylink.io/api/collections/c8a4e1f6-3b72-4d09-95e3-0f1b7d2a6c48/items/61f0b8d3-a2c7-4e95-8b14-7d3e0a9c2f56 \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "version": null }'await steadylink.pinCollectionItem(collectionId, itemId, null);Remove an item#
/api/collections/{collection_id}/items/{item_id}assets:writeRemoves the item from the collection, not the file. Returns 204 No Content. In JavaScript: steadylink.removeFromCollection(collectionId, itemId).
Get portal analytics#
/api/collections/{collection_id}/analyticsassets:readReturns total views and downloads plus up to the last 90 days that had activity, oldest first.
{
"viewCount": 312,
"downloadCount": 87,
"days": [
{ "day": "2026-10-07", "views": 41, "downloads": 9 },
{ "day": "2026-10-08", "views": 18, "downloads": 3 }
]
}A view is counted each time the portal data is loaded; a download each time an item is downloaded. Previews are not counted as downloads.
Public: exchange a password for an access token#
/api/public/collections/{slug}/unlockSend { "password": "tulip-harbor-91" }. Returns { "accessToken": "...", "expiresIn": 3600 }, a token valid for one hour or until the collection expires, whichever is sooner. Pass it as the access query parameter on the other public routes. A wrong password returns 401.
Public: read a portal#
/api/public/collections/{slug}Query parameters
accessstring- The token from the password exchange. Required for
passwordcollections. countbooleanDefaulttrue- Set
falseto load the portal without counting a view, for example when re-rendering.
Returns the collection fields, the resolved branding, and items as in Get a collection. The resolved branding includes showSteadyLinkBranding, and falls back to the workspace name and default colors when no custom branding applies. Errors: 404 for an unknown or private collection, 410 once expired, 401 with "code": "collection_password_required" when a password collection is opened without a valid token.
Public: preview an item#
/api/public/collections/{slug}/items/{item_id}/previewRedirects (307) to a short-lived URL for inline display, or streams the bytes for encrypted files. Previews count toward the workspace's delivery usage but not toward portal downloads.
Public: download an item#
/api/public/collections/{slug}/items/{item_id}/downloadRedirects (307) to a short-lived URL that downloads the file under its name, or streams it for encrypted files. Counts a portal download and delivery usage. Returns 409 if the revision has not passed scanning.