Asset requests
Collect a defined set of files from people outside your workspace through one expiring link, then review each file before it lands in a bucket.
On this page
An asset request (an "upload form" in the dashboard) is a link you send to a client, partner, or contributor so they can upload the files you asked for, without an account and without seeing anything else in your workspace. Each file they send waits in temporary storage until someone in your workspace approves it into a bucket, uses it to replace an existing file, or rejects it. This guide covers building the request, what the sender experiences, and how review decisions work.
To get a new version of one specific file, a replacement request is simpler. To accept uploads continuously from your own website, use the upload widget.
Plan the request#
A request has a name, optional instructions, an expiration, and between 1 and 25 requested files (called fields in the API). Each requested file is one slot the sender fills with one upload, and each has its own rules:
| Rule | API field | Notes |
|---|---|---|
| Label | label | What the sender sees, such as "Hero image". Up to 200 characters, cannot be blank. |
| Description | description | Optional per-file instructions, up to 2,000 characters. |
| Required | required | Defaults to true. The sender cannot submit until every required file is uploaded. |
| Accepted types | acceptedTypes | Up to 20 entries. Each is an exact MIME type (application/pdf), a wildcard (image/*), or an extension with its dot (.ai). A file passes if it matches any entry. An empty list accepts any type. |
| Size limit | maxBytes | Per-file maximum. It cannot exceed your plan's upload size limit, otherwise creating the request fails with 413 and the code upload_size_limit. |
| Destination bucket | bucketId | Where an approved file goes. Each requested file can target a different bucket. |
| Replacement target | replacementAssetId | Optional. When set, approving this file publishes it as a new revision of that existing file instead of creating a new one. |
Plan upload size limits are 100 MB on Free, 250 MB on Personal, 500 MB on Pro, 2 GB on Business, and 5 GB on Enterprise. The limit is checked again when the sender uploads, so a request created on a higher plan uses the current plan's limit if the workspace changes plan.
The expiration must be in the future and no more than one year away. After it passes, the link stops accepting uploads.
Create the request#
In the dashboard#
Open upload forms
Open Requests, switch to Upload forms, and choose New upload form.
Describe what you need
Enter a form name (for example "Q3 partner logos"), optional instructions, the Requested item label, and an Expires date. The link stays open until the end of that day.
Set the rules
Choose the destination Bucket, the accepted file types, and Max size (MB).
Share the link
Choose Create, then Copy link on the new row.
The dashboard creates a request with one requested file. For several files with different rules or destinations, create the request through the API.
With the API#
curl -X POST https://api.steadylink.io/api/request-forms \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Northwind launch package",
"instructions": "Use the final approved exports. Vector logos only.",
"expiresAt": "2026-10-31T23:59:59Z",
"files": [
{
"label": "Primary logo",
"description": "SVG or AI, transparent background.",
"required": true,
"acceptedTypes": [".svg", ".ai"],
"maxBytes": 26214400,
"bucketId": "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c"
},
{
"label": "Hero image",
"required": true,
"acceptedTypes": ["image/*"],
"maxBytes": 104857600,
"bucketId": "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c",
"replacementAssetId": "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d"
},
{
"label": "Press release",
"required": false,
"acceptedTypes": ["application/pdf", ".docx"],
"maxBytes": 10485760,
"bucketId": "7a2d9f40-1b3c-4e5d-8f6a-0b1c2d3e4f50"
}
]
}'const form = await steadylink.request<{ id: string; token: string; publicPath: string }>("/api/request-forms", {
method: "POST",
body: JSON.stringify({
name: "Northwind launch package",
instructions: "Use the final approved exports. Vector logos only.",
expiresAt: "2026-10-31T23:59:59Z",
files: [
{ label: "Primary logo", required: true, acceptedTypes: [".svg", ".ai"], maxBytes: 26_214_400, bucketId: "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c" },
{ label: "Hero image", required: true, acceptedTypes: ["image/*"], maxBytes: 104_857_600, bucketId: "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c", replacementAssetId: "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d" },
],
}),
});
const link = `https://steadylink.io${form.publicPath}`;form = client.request("POST", "/api/request-forms", {
"name": "Northwind launch package",
"instructions": "Use the final approved exports. Vector logos only.",
"expiresAt": "2026-10-31T23:59:59Z",
"files": [
{"label": "Primary logo", "required": True, "acceptedTypes": [".svg", ".ai"], "maxBytes": 26214400, "bucketId": "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c"},
{"label": "Hero image", "required": True, "acceptedTypes": ["image/*"], "maxBytes": 104857600, "bucketId": "0c9e4b1a-6d2f-4a7e-b3c5-8f1d2e3a4b5c", "replacementAssetId": "3f2a9c1e-7b4d-4e8a-9c21-5d6f0a1b2c3d"},
],
})
link = "https://steadylink.io" + form["publicPath"]{
"id": "e2b6c8d4-5f1a-4c7e-9a3b-6d0e1f2a3b4c",
"token": "pX3kV9qL...",
"publicPath": "/request/pX3kV9qL..."
}Validation you may run into:
422"Every destination must be a bucket in this workspace" when abucketIdis wrong.422"Every replacement target must be an asset in this workspace" when areplacementAssetIdis wrong.422"Expiration must be within the next year".409with the codefeature_limit_reachedwhen the workspace already has its maximum number of open requests (see Limits).
Unlike replacement links, the request link can be read again later: GET /api/request-forms returns each form's publicPath.
What the sender sees#
The link opens a page at https://steadylink.io/request/{token} with your request name, instructions, and one drop zone per requested file. The sender can add their name and email (both optional), uploads each file, and chooses Send for approval. The page checks type and size before uploading and explains a mismatch ("This file type is not accepted", or the size limit in readable units). If a required file is missing, it names the missing items.
Each file goes straight to temporary storage over a presigned URL that is valid for 15 minutes. If an upload is interrupted, starting it again for the same slot discards the partial upload. Once a file has finished uploading, its slot is filled for that submission and another upload for it returns 409 "This requested file has already been uploaded". After submitting, the sender can choose Request more files to send another set through the same link, which creates a separate submission.
The page tells the sender that uploads stay in temporary storage until they are approved. Nothing is added to your buckets, and no existing file changes, until you decide.
Public route limits#
The public routes are rate-limited per IP address to protect your workspace from abuse:
| Action | Limit |
|---|---|
| Open the request | 120 per minute |
| Start a submission | 20 per hour |
| Start a file upload | 60 per hour |
| Confirm a file upload | 60 per hour |
| Submit | 30 per hour |
Uploads also count against the workspace's temporary intake capacity, so a sender can be refused when the workspace is out of room.
Review submissions#
Submitted files appear in Requests > Review queue, newest first, with who sent them, which request and requested item they belong to, and their size. Select a file to preview it (images, audio, video, PDF, plain text, CSV, and JSON preview inline; SVG and HTML never do, for safety) or download it, then choose Approve or Reject.
In the API, GET /api/request-forms/submissions lists the latest 200 submissions with their files, and each file is decided with POST /api/request-forms/files/{file_id}/decision. There are three actions:
action | What happens |
|---|---|
approve | Saves the file. With a replacement target (from the requested file, or replacementAssetId in the decision), it becomes a new revision of that file and keeps its stable link. Otherwise it is added as a new file in the destination bucket, inside a requests/ folder. |
reject | Deletes the temporary upload. The file status becomes rejected. |
assign | Changes the destination bucket without approving. A later approve uses the assigned bucket. |
The bucket used on approval is, in order of preference: bucketId in the decision, the bucket set by an earlier assign, then the requested file's own bucket.
# Route a file to another bucket, then approve it
curl -X POST https://api.steadylink.io/api/request-forms/files/9d4c2a71-0e3b-4f6a-8c1d-2b3e4f5a6b7c/decision \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "assign", "bucketId": "7a2d9f40-1b3c-4e5d-8f6a-0b1c2d3e4f50" }'
curl -X POST https://api.steadylink.io/api/request-forms/files/9d4c2a71-0e3b-4f6a-8c1d-2b3e4f5a6b7c/decision \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "approve", "note": "Approved for the launch campaign" }'{ "status": "approved", "assetId": "5b8e1f2a-3c4d-4e6f-9a0b-1c2d3e4f5a6b" }Details that matter during review:
- Names never overwrite. A new file approved into
requests/gets a unique name if one with the same name exists (logo.svgbecomeslogo-2.svg). - Replacements must match the bucket. The replacement target has to live in the bucket used for approval; otherwise approval fails with
422"Replacement asset is not in the selected bucket". - Decisions are final. Approving or rejecting a file that is no longer waiting returns
409"This file is not awaiting approval". - Notes are recorded. The optional
note(up to 2,000 characters) is stored with the decision, along with who decided and when.
Edit, re-link, or close a request#
- Edit with
PUT /api/request-forms/{form_id}, sending the full request. You can always change the name, instructions, and expiration of an open request. The requested files themselves can only change until the first submission arrives; after that, changing them returns409"File requirements cannot change after responses begin". Include each existing requested file'sidto keep it. - Generate a new link with Generate new link in the dashboard or
POST /api/request-forms/{form_id}/rotate. The old link stops working immediately. - Revoke with Revoke in the dashboard or
DELETE /api/request-forms/{form_id}. The link returns410 Gonewith "This request link was revoked". Files already received stay in the review queue.
Only open requests (not revoked, not expired) can be edited or given a new link.
Expiry#
An expired link returns 410 Gone with "This request link has expired" and accepts nothing. SteadyLink then cleans up: temporary files from that request that were never decided are deleted and marked expired, so review anything you need before the expiration. Members who have the link.expiring notification turned on are told when an open request has less than 24 hours left (see Notifications).
Limits#
| Plan | Open requests at once |
|---|---|
| Free | 2 |
| Personal | 10 |
| Pro | 50 |
| Business | 250 |
| Enterprise | 2,500 |
Only requests that are neither revoked nor expired count. Revoking one frees a slot immediately.
Creating, editing, and deciding requires a workspace role that can edit files (member, admin, or owner) or an API key with assets:write. Listing requests and submissions, previewing, and downloading need assets:read.