Requests and migration API
Collect files from people outside your workspace, schedule revisions, import a website's media, and manage personal notification preferences.
On this page
- Access model
- Replacement requests
- Create a replacement request
- List replacement requests for a file
- List replacement requests in the workspace
- Approve a replacement
- Reject a replacement
- Rotate a replacement link
- Revoke a replacement request
- Recipient: read the request
- Recipient: get an upload URL
- Recipient: submit the upload
- Scheduled revisions
- Schedule a revision
- List scheduled revisions
- Cancel a scheduled revision
- Request forms
- Create a request form
- List request forms
- Update a request form
- Rotate a request form link
- Revoke a request form
- List submissions
- Preview or download a submitted file
- Decide on a submitted file
- Public request form flow
- Website migration
- Start a scan
- List scans
- Get a scan and its media
- Cancel a scan
- Retry a failed scan
- Import media
- Export the URL map
- Personal notification preferences
- Get preferences
- Save a preference
- Read notifications
- Next steps
These routes automate the review-oriented workflows: asking someone to replace one file, collecting several files through a request form, publishing a revision at a set time, and moving an existing website's media into SteadyLink. Each section lists the management routes your integration calls, then the public routes the recipient's browser calls.
Access model#
- Management routes accept a signed-in session or an API key. With a key,
GETroutes needassets:readand every other method needsassets:write. Signed-in viewers can read but not change anything. - Public routes are authorized only by the secret token in the URL. They never see your API key, so you can hand the link to an agency, a client, or a freelancer. Treat the token like a password: anyone with the link can upload until it expires or you revoke it.
- Tokens are shown once. Creating a replacement request or a request form returns its token a single time. If you lose a replacement request's link, rotate it; request forms can rotate too, and their list response also includes the current link.
- Nothing goes live without review. Files uploaded through public routes sit in temporary storage until someone in the workspace approves them. Rejecting or revoking deletes the temporary bytes.
Replacement requests#
A replacement request lets one person upload a new version of one existing file without an account. When you approve the upload, it becomes a new revision of that file, so its stable link starts serving the new bytes. The recipient opens https://steadylink.io/replace/{token}.
Statuses: pending (waiting for an upload), submitted (waiting for your decision), approved, scheduled (approved for a future publish time), rejected, and revoked.
Create a replacement request#
/api/assets/{asset_id}/replacement-requestsassets:writeThe file must be in a bucket (409 otherwise).
Body
recipientNamestring- Shown to the recipient, up to 200 characters.
notestring- Instructions shown to the recipient, up to 2000 characters.
expiresInDaysintegerDefault7- From 1 to 30.
curl -X POST https://api.steadylink.io/api/assets/3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44/replacement-requests \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "recipientName": "Agency producer", "note": "Upload the approved hero image", "expiresInDays": 7 }'{
"id": "4b9e1d73-2c8a-4f05-b7e6-9a3d0c1f5e82",
"assetId": "3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44",
"recipientName": "Agency producer",
"note": "Upload the approved hero image",
"status": "pending",
"filename": null,
"byteSize": null,
"mime": null,
"expiresAt": "2026-10-15T10:30:00.000000",
"submittedAt": null,
"decidedAt": null,
"createdAt": "2026-10-08T10:30:00.000000",
"token": "Zt7c...",
"sharePath": "/replace/Zt7c..."
}Send the recipient https://steadylink.io followed by sharePath.
List replacement requests for a file#
/api/assets/{asset_id}/replacement-requestsassets:readReturns the 50 most recent requests for the file, newest first, in the same shape as above without token.
List replacement requests in the workspace#
/api/assets/replacement-requestsassets:readReturns the 200 most recent requests across the workspace, each with an extra assetName. Use it to build a review queue: filter for "status": "submitted".
Approve a replacement#
/api/assets/{asset_id}/replacement-requests/{request_id}/approveassets:writeTurns the submitted upload into a new revision of the file. With an empty body, the new revision is published immediately.
Body
publishAtdatetime- Publish the new revision at this time instead of now. The file keeps serving its current revision until then.
rollbackAtdatetime- Switch back to the revision that was current before this approval at this time. Must be after
publishAtwhen both are set.
Times are ISO 8601. Include an offset or Z; a time without one is read as UTC. A schedule must be at least 10 seconds in the future and within one year (422 otherwise).
curl -X POST https://api.steadylink.io/api/assets/3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44/replacement-requests/4b9e1d73-2c8a-4f05-b7e6-9a3d0c1f5e82/approve \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "publishAt": "2026-11-01T14:00:00Z", "rollbackAt": "2026-11-08T14:00:00Z" }'{ "approved": true, "version": 5, "scheduledJobId": "e81f3c42-7a5d-4b90-8c16-3f2e9d0a7b54" }version is the new revision number. scheduledJobId is set when you passed publishAt; cancel it with Cancel a scheduled revision. Returns 409 if the request is not submitted.
Reject a replacement#
/api/assets/{asset_id}/replacement-requests/{request_id}/rejectassets:writeDeletes the uploaded candidate and closes the request. The public link stops working (410). Returns { "rejected": true }, or 409 if nothing is awaiting a decision. To let the recipient try again, create a new request.
Rotate a replacement link#
/api/assets/{asset_id}/replacement-requests/{request_id}/rotate-linkassets:writeIssues a new token for a pending or submitted request that has not expired, and returns the request with the new token and sharePath. The old link stops working immediately (404). Use it when a link was sent to the wrong person or you lost the original.
Revoke a replacement request#
/api/assets/{asset_id}/replacement-requests/{request_id}assets:writeCloses the request in any state and deletes any uploaded candidate. Returns { "revoked": true }.
Recipient: read the request#
/api/replacement-requests/{token}Returns what the recipient needs to see: the request fields, assetName, workspaceName, and canUpload (true while pending or submitted). Returns 404 for an unknown token and 410 once the request has expired, been rejected, or been revoked.
Recipient: get an upload URL#
/api/replacement-requests/{token}/uploadBody
sizeintegerRequired- Exact file size in bytes, up to 2 GB.
contentTypestringDefaultapplication/octet-stream- The file's MIME type.
{
"uploadUrl": "<presigned PUT URL>",
"tempKey": "uploads/temp/replacement-requests/4b9e1d73-.../9f0c..."
}PUT the bytes to uploadUrl with Content-Type: application/octet-stream. Calling this route again replaces the previous candidate, so a recipient can re-upload until you decide, even after submitting.
Recipient: submit the upload#
/api/replacement-requests/{token}/submitBody
tempKeystringRequired- The
tempKeyfrom the upload step. filenamestringRequired- Original file name, up to 300 characters.
contentTypestringDefaultapplication/octet-stream- The file's MIME type.
Checks that the bytes arrived and match the declared size, then moves the request to submitted. Returns { "submitted": true, "filename": "hero-final.webp", "byteSize": 482113 }. Errors: 400 if the key belongs to another request, the object is missing, or the size differs.
Scheduled revisions#
Schedule a revision that already exists to become current at a set time, for example a sale banner that should go live at midnight and revert a week later. The revision must be retained; see Revisions.
Schedule a revision#
/api/assets/{asset_id}/scheduled-revisionsassets:writeBody
versionintegerRequired- Revision number to publish.
publishAtdatetimeRequired- When to make it current. At least 10 seconds in the future and within one year.
rollbackAtdatetime- When to switch back to the revision that is current now. Must be after
publishAt.
curl -X POST https://api.steadylink.io/api/assets/3f2a9c1e-6b7d-4e21-9a0c-1d5e8f7b2a44/scheduled-revisions \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "version": 12, "publishAt": "2026-11-27T05:00:00Z", "rollbackAt": "2026-12-04T05:00:00Z" }'{ "id": "a07d5e18-3b4c-4f29-9e61-0d8c2b7f4a35", "status": "queued", "publishAt": "2026-11-27T05:00:00" }The rollback target is fixed when you schedule: it is the revision that was current at that moment, even if other changes happen in between. Returns 404 if the revision does not exist.
List scheduled revisions#
/api/assets/{asset_id}/scheduled-revisionsassets:readReturns jobs that are queued or processing, soonest first. Each item has id, version, publishAt, rollbackAt, and status.
Cancel a scheduled revision#
/api/assets/{asset_id}/scheduled-revisions/{job_id}assets:writeCancels a job while it is still queued and returns { "cancelled": true }. Once it has started processing, the request fails with 409.
Request forms#
A request form asks one or more people for a set of files, for example "logo in SVG, hero image, and signed release PDF". Each requested file has its own type and size rules and its own destination bucket. Respondents open https://steadylink.io/request/{token}. Each submission is reviewed file by file.
Active forms per plan: 2 on Hobby, 10 on Personal, 50 on Pro, 250 on Business. A form stops counting once it expires or is revoked. Creating one more returns 409 with "code": "feature_limit_reached".
Create a request form#
/api/request-formsassets:writeBody
namestringRequired- Form title shown to respondents, up to 200 characters.
instructionsstring- Shown above the file list, up to 5000 characters.
expiresAtdatetimeRequired- When the link stops accepting files. Must be in the future and within one year.
filesobject[]Required- From 1 to 25 requested files. Fields below.
Requested file
labelstringRequired- For example
Hero image. Up to 200 characters. descriptionstring- Extra guidance, up to 2000 characters.
requiredbooleanDefaulttrue- The submission cannot be completed without it.
acceptedTypesstring[]- Up to 20 rules. Each is a MIME type (
application/pdf), a wildcard (image/*), or an extension (.svg). Empty accepts any type. maxBytesintegerRequired- Per-file limit. Cannot exceed the plan's upload limit (
413otherwise). bucketIduuidRequired- Where an approved file goes.
replacementAssetIduuid- Approve this file as a new revision of an existing file instead of creating a new one.
curl -X POST https://api.steadylink.io/api/request-forms \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Launch package",
"instructions": "Use final approved exports.",
"expiresAt": "2026-10-31T23:59:59Z",
"files": [
{ "label": "Hero image", "required": true, "acceptedTypes": ["image/*"], "maxBytes": 26214400, "bucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10" },
{ "label": "Signed release", "required": false, "acceptedTypes": [".pdf"], "maxBytes": 10485760, "bucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10" }
]
}'{
"id": "5d2f8a61-0e7b-4c93-a4d8-1b6e3f9c0a27",
"token": "mR4u...",
"publicPath": "/request/mR4u..."
}Every bucketId and replacementAssetId must belong to the workspace (422 otherwise).
List request forms#
/api/request-formsassets:readReturns every form, newest first, with its requested files (each with an id), submissionCount, revokedAt, and the current publicPath.
Update a request form#
/api/request-forms/{form_id}assets:writeReplaces the form's name, instructions, expiration, and requested files. The body has the same fields as create; include each existing file's id to keep it, omit a file to remove it, and add files without an id. Returns the updated form.
Once anyone has started a submission, the requested files are locked: changing them returns 409, but you can still edit the name, instructions, and expiration. Expired or revoked forms cannot be edited (409).
Rotate a request form link#
/api/request-forms/{form_id}/rotateassets:writeIssues a new token and returns { "publicPath": "/request/..." }. The old link returns 404 immediately; submissions already made are kept.
Revoke a request form#
/api/request-forms/{form_id}assets:writeCloses the form. Its public link then returns 410. Returns { "revoked": true }. Existing submissions stay available for review.
List submissions#
/api/request-forms/submissionsassets:readReturns the 200 most recent submissions, newest first.
{
"items": [
{
"id": "c3e9a0f2-71b4-4d58-8e2a-6f1d0b7c9e43",
"formId": "5d2f8a61-0e7b-4c93-a4d8-1b6e3f9c0a27",
"formName": "Launch package",
"respondentName": "Jo Park",
"respondentEmail": "[email protected]",
"status": "submitted",
"submittedAt": "2026-10-09T16:02:11.000000",
"files": [
{
"id": "8a1f6c3d-2e9b-4075-b4c1-9d0e7f2a5b68",
"fieldId": "f2b7d0e4-6c1a-4389-a5f3-0e8d2c9b1a76",
"label": "Hero image",
"filename": "hero-final.webp",
"mime": "image/webp",
"byteSize": 482113,
"status": "submitted",
"assetId": null
}
]
}
]
}A submission is draft until the respondent finishes it. Each file is uploading, submitted (awaiting your decision), approved, or rejected.
Preview or download a submitted file#
/api/request-forms/files/{file_id}/previewassets:read/api/request-forms/files/{file_id}/downloadassets:readBoth return short-lived URLs (5 minutes) to the temporary upload so a reviewer can inspect it before deciding. Download returns { "url": "..." }. Preview returns url, mime, filename, previewable, and expiresIn; it renders inline only for images, audio, video, PDF, JSON, CSV, and plain text. SVG and HTML are never previewed inline, and previewable is false for them. Both return 404 once the file has been approved or rejected.
Decide on a submitted file#
/api/request-forms/files/{file_id}/decisionassets:writeBody
actionstringRequiredapprove,reject, orassign.bucketIduuid- For
assignandapprove: the destination bucket. Defaults to the requested file's bucket. replacementAssetIduuid- For
approve: publish as a new revision of this file. It must be in the destination bucket. notestring- Decision note for your records, up to 2000 characters.
approvewithout a replacement target creates a new file in therequests/folder of the destination bucket, renaming it if the name is taken. Returns{ "status": "approved", "assetId": "..." }.approvewith a replacement target (from the body or the requested file) adds a new revision to that file.rejectdeletes the temporary file and returns{ "status": "rejected", "assetId": null }.assignonly changes the destination for a later approval and returns{ "assigned": true }.
curl -X POST https://api.steadylink.io/api/request-forms/files/8a1f6c3d-2e9b-4075-b4c1-9d0e7f2a5b68/decision \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "approve", "note": "Approved for campaign use" }'approve and reject return 409 if the file is not awaiting a decision.
Public request form flow#
The respondent's browser, or your own upload page, calls these routes with the form token. No credentials are involved. Limits are per IP address and return 429 when exceeded.
| Step | Route | Limit |
|---|---|---|
| Read the form | GET /api/public/request-forms/{token} | 120 per minute |
| Start a submission | POST /api/public/request-forms/{token}/submissions | 20 per hour |
| Get an upload URL for one file | POST /api/public/request-forms/{token}/submissions/{submission_id}/uploads | 60 per hour |
| Confirm one file | POST /api/public/request-forms/{token}/submissions/{submission_id}/uploads/complete | 60 per hour |
| Finish the submission | POST /api/public/request-forms/{token}/submissions/{submission_id}/complete | 30 per hour |
Every step returns 404 for an unknown token and 410 once the form has expired or been revoked.
Read the form
GET /api/public/request-forms/{token}returnsid,name,instructions,expiresAt, andfiles. Use each file'sidasfieldIdbelow, and itsacceptedTypesandmaxBytesto validate before uploading.Start a submission
POST .../submissionswith optional{ "name": "Jo Park", "email": "[email protected]" }. Returns201 Createdwith{ "id": "<submission_id>" }.Upload each file
POST .../submissions/{submission_id}/uploadswith{ "fieldId": "...", "filename": "hero-final.webp", "mime": "image/webp", "size": 482113 }. It returns{ "fileId": "...", "uploadUrl": "..." }.PUTthe bytes touploadUrlwithin 15 minutes, withContent-Type: application/octet-stream.Errors:
413above the smaller of the field'smaxBytesand the plan's upload limit,415for a type that matches none ofacceptedTypes,409if this field was already confirmed in this submission. Calling it again for a field that was not confirmed discards the earlier attempt.Confirm each file
POST .../uploads/completewith{ "fileId": "..." }. SteadyLink checks that the stored size matchessize(422otherwise) and returns{ "complete": true }. Safe to repeat.Finish
POST .../submissions/{submission_id}/completereturns{ "complete": true }, or422if a required file is still missing. The workspace is notified with arequest.submittedevent.
Website migration#
Scan a public website for the media it uses, import the files you choose into a bucket, and download a map from each old URL to its new stable link. The scanner only fetches public http and https addresses on ports 80 and 443, and it follows redirects only to addresses that pass the same check.
Scan statuses: queued, running, complete, failed, cancelled, and importing.
Start a scan#
/api/migration-scansassets:writeBody
urlstringRequired- Site to scan.
https://is added when no scheme is given. maxPagesintegerDefault50- Pages to crawl, from 1 to 200.
curl -X POST https://api.steadylink.io/api/migration-scans \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://www.northwind.example", "maxPages": 50 }'{
"id": "0f6b3d82-9c1e-4a57-b2d4-8e7a1c5f3b90",
"url": "https://www.northwind.example",
"status": "queued",
"maxPages": 50,
"pagesScanned": 0,
"mediaFound": 0,
"progress": 0,
"error": null,
"createdAt": "2026-10-08T11:00:00.000000",
"updatedAt": "2026-10-08T11:00:00.000000"
}Limits: 20 scans per workspace per rolling 24 hours (429), and a monthly page allowance of 100 on Hobby, 1,000 on Personal, 10,000 on Pro, and 100,000 on Business. A queued or running scan reserves its full maxPages; a finished scan counts the pages it actually scanned. Exceeding the allowance returns 409 with "code": "feature_limit_reached". A private or unreachable address returns 422.
List scans#
/api/migration-scansassets:readReturns the 100 most recent scans, newest first.
Get a scan and its media#
/api/migration-scans/{scan_id}assets:readReturns the scan plus media, sorted by how often each file is referenced.
{
"id": "0f6b3d82-9c1e-4a57-b2d4-8e7a1c5f3b90",
"status": "complete",
"pagesScanned": 48,
"mediaFound": 212,
"...": "other scan fields",
"media": [
{
"id": "b5e2c8f1-4d7a-4e90-8b3c-1a6f0d9e2c47",
"originalUrl": "https://www.northwind.example/wp-content/uploads/hero.jpg",
"mediaType": "image",
"contentType": "image/jpeg",
"byteSize": 734002,
"statusCode": 200,
"broken": false,
"pageUrls": ["https://www.northwind.example/", "https://www.northwind.example/about"],
"references": 2,
"importedAssetId": null,
"newUrl": null
}
]
}broken media could not be fetched and cannot be imported. After import, importedAssetId and newUrl (the stable link) are filled in.
Cancel a scan#
/api/migration-scans/{scan_id}/cancelassets:writeStops a queued, running, or importing scan. Returns { "cancelled": true }, or 409 once it has finished.
Retry a failed scan#
/api/migration-scans/{scan_id}/retryassets:writeQueues a failed scan again and returns it with status 202 Accepted. A scan whose import failed is not retried this way; start the import again instead.
Import media#
/api/migration-scans/{scan_id}/importsassets:writeBody
mediaIdsuuid[]Required- From 1 to 200 media IDs from this scan. Each must be not broken and not yet imported.
bucketIduuidRequired- Destination bucket.
pathstringDefaultwebsite-import- Destination folder. Nested paths like
website-import/imagesare allowed; empty,., and..segments are not.
curl -X POST https://api.steadylink.io/api/migration-scans/0f6b3d82-9c1e-4a57-b2d4-8e7a1c5f3b90/imports \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "mediaIds": ["b5e2c8f1-4d7a-4e90-8b3c-1a6f0d9e2c47"], "bucketId": "9b1c7e52-0f3a-4c6d-8e2b-5a7d1c3e9f10", "path": "website-import/images" }'{ "queued": true }The scan moves to importing and back to complete when done; poll the scan for progress. The scan must be complete, or failed during an earlier import, which lets you resume. Anything else returns 422. To import more than 200 files, send several requests one after another.
Export the URL map#
/api/migration-scans/{scan_id}/export/{format}assets:readformat is csv or json. Both list only imported media, as originalUrl and steadyLinkUrl pairs. CSV downloads as steadylink-mapping-{scan_id}.csv. JSON adds replacement instructions and snippet examples:
{
"scanId": "0f6b3d82-9c1e-4a57-b2d4-8e7a1c5f3b90",
"mapping": [
{
"originalUrl": "https://www.northwind.example/wp-content/uploads/hero.jpg",
"steadyLinkUrl": "https://cdn.steadylink.io/a/7c3e9f10-2b5d-4a86-9e1f-0d4c8b2a6e53"
}
],
"instructions": "Replace URLs in source control, review the diff, test, and deploy through your normal release process.",
"examples": { "html": "...", "nextjs": "...", "css": "..." }
}Personal notification preferences#
Notifications belong to a person, not a workspace integration, so these routes require a signed-in session. API keys are refused: reads return 403, and saving a preference returns 422.
Events: asset.replaced, asset.rolled_back, bandwidth.unusual, job.failed, link.expiring, request.submitted, schedule.published, traffic.unusual. Channels: in_app, email, webhook.
Get preferences#
/api/notifications/preferencesReturns one effective preference per event. Your own override wins; otherwise the workspace default applies ("inherited": true); with neither, the event is enabled, in-app, and immediate.
{
"events": ["asset.replaced", "asset.rolled_back", "bandwidth.unusual", "job.failed", "link.expiring", "request.submitted", "schedule.published", "traffic.unusual"],
"channels": ["email", "in_app", "webhook"],
"items": [
{ "eventType": "request.submitted", "channels": ["email", "in_app"], "mode": "immediate", "enabled": true, "inherited": false },
{ "eventType": "job.failed", "channels": ["in_app"], "mode": "immediate", "enabled": true, "inherited": false }
]
}Save a preference#
/api/notifications/preferencesSaves your override for one event in the selected workspace.
Body
eventTypestringRequired- One of the events above.
channelsstring[]Default["in_app"]- At least one of
in_app,email,webhook. modestringDefaultimmediateimmediateordigest.enabledbooleanDefaulttrue- Set
falseto stop this event for you.
curl -X PUT https://api.steadylink.io/api/notifications/preferences \
-H "Authorization: Bearer $STEADYLINK_ACCESS_TOKEN" \
-H "X-Workspace-Id: 6e0b2f7a-1c94-4d3b-a8e5-2f71c0d9b6a3" \
-H "Content-Type: application/json" \
-d '{ "eventType": "request.submitted", "channels": ["in_app", "email"], "mode": "immediate", "enabled": true }'{ "eventType": "request.submitted", "channels": ["email", "in_app"], "mode": "immediate", "enabled": true }An unknown event, channel, or mode returns 422.
Read notifications#
/api/notificationsReturns your 100 most recent notifications in the workspace and an unread count. Mark one read with POST /api/notifications/{notification_id}/read. GET /api/notifications/deliveries lists recent email and webhook delivery attempts for your notifications, with status, attempts, and lastError.