Skip to content

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

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, GET routes need assets:read and every other method needs assets: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#

POST/api/assets/{asset_id}/replacement-requests
Requiresassets:write

The 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.
expiresInDaysintegerDefault 7
From 1 to 30.
curl
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 }'
200 OKResponse
{
  "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#

GET/api/assets/{asset_id}/replacement-requests
Requiresassets:read

Returns the 50 most recent requests for the file, newest first, in the same shape as above without token.

List replacement requests in the workspace#

GET/api/assets/replacement-requests
Requiresassets:read

Returns 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#

POST/api/assets/{asset_id}/replacement-requests/{request_id}/approve
Requiresassets:write

Turns 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 publishAt when 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
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" }'
200 OKResponse
{ "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#

POST/api/assets/{asset_id}/replacement-requests/{request_id}/reject
Requiresassets:write

Deletes 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.

POST/api/assets/{asset_id}/replacement-requests/{request_id}/rotate-link
Requiresassets:write

Issues 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#

DELETE/api/assets/{asset_id}/replacement-requests/{request_id}
Requiresassets:write

Closes the request in any state and deletes any uploaded candidate. Returns { "revoked": true }.

Recipient: read the request#

GET/api/replacement-requests/{token}
No API key. Public route.

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#

POST/api/replacement-requests/{token}/upload
No API key. Public route.

Body

sizeintegerRequired
Exact file size in bytes, up to 2 GB.
contentTypestringDefault application/octet-stream
The file's MIME type.
200 OKResponse
{
  "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#

POST/api/replacement-requests/{token}/submit
No API key. Public route.

Body

tempKeystringRequired
The tempKey from the upload step.
filenamestringRequired
Original file name, up to 300 characters.
contentTypestringDefault application/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#

POST/api/assets/{asset_id}/scheduled-revisions
Requiresassets:write

Body

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
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" }'
200 OKResponse
{ "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#

GET/api/assets/{asset_id}/scheduled-revisions
Requiresassets:read

Returns jobs that are queued or processing, soonest first. Each item has id, version, publishAt, rollbackAt, and status.

Cancel a scheduled revision#

DELETE/api/assets/{asset_id}/scheduled-revisions/{job_id}
Requiresassets:write

Cancels 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#

POST/api/request-forms
Requiresassets:write

Body

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.
requiredbooleanDefault true
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 (413 otherwise).
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
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" }
    ]
  }'
201 CreatedResponse
{
  "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#

GET/api/request-forms
Requiresassets:read

Returns every form, newest first, with its requested files (each with an id), submissionCount, revokedAt, and the current publicPath.

Update a request form#

PUT/api/request-forms/{form_id}
Requiresassets:write

Replaces 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).

POST/api/request-forms/{form_id}/rotate
Requiresassets:write

Issues a new token and returns { "publicPath": "/request/..." }. The old link returns 404 immediately; submissions already made are kept.

Revoke a request form#

DELETE/api/request-forms/{form_id}
Requiresassets:write

Closes the form. Its public link then returns 410. Returns { "revoked": true }. Existing submissions stay available for review.

List submissions#

GET/api/request-forms/submissions
Requiresassets:read

Returns the 200 most recent submissions, newest first.

200 OKResponse
{
  "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#

GET/api/request-forms/files/{file_id}/preview
Requiresassets:read
GET/api/request-forms/files/{file_id}/download
Requiresassets:read

Both 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#

POST/api/request-forms/files/{file_id}/decision
Requiresassets:write

Body

actionstringRequired
approve, reject, or assign.
bucketIduuid
For assign and approve: 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.
  • approve without a replacement target creates a new file in the requests/ folder of the destination bucket, renaming it if the name is taken. Returns { "status": "approved", "assetId": "..." }.
  • approve with a replacement target (from the body or the requested file) adds a new revision to that file.
  • reject deletes the temporary file and returns { "status": "rejected", "assetId": null }.
  • assign only changes the destination for a later approval and returns { "assigned": true }.
curl
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.

StepRouteLimit
Read the formGET /api/public/request-forms/{token}120 per minute
Start a submissionPOST /api/public/request-forms/{token}/submissions20 per hour
Get an upload URL for one filePOST /api/public/request-forms/{token}/submissions/{submission_id}/uploads60 per hour
Confirm one filePOST /api/public/request-forms/{token}/submissions/{submission_id}/uploads/complete60 per hour
Finish the submissionPOST /api/public/request-forms/{token}/submissions/{submission_id}/complete30 per hour

Every step returns 404 for an unknown token and 410 once the form has expired or been revoked.

  1. Read the form

    GET /api/public/request-forms/{token} returns id, name, instructions, expiresAt, and files. Use each file's id as fieldId below, and its acceptedTypes and maxBytes to validate before uploading.

  2. Start a submission

    POST .../submissions with optional { "name": "Jo Park", "email": "[email protected]" }. Returns 201 Created with { "id": "<submission_id>" }.

  3. Upload each file

    POST .../submissions/{submission_id}/uploads with { "fieldId": "...", "filename": "hero-final.webp", "mime": "image/webp", "size": 482113 }. It returns { "fileId": "...", "uploadUrl": "..." }. PUT the bytes to uploadUrl within 15 minutes, with Content-Type: application/octet-stream.

    Errors: 413 above the smaller of the field's maxBytes and the plan's upload limit, 415 for a type that matches none of acceptedTypes, 409 if this field was already confirmed in this submission. Calling it again for a field that was not confirmed discards the earlier attempt.

  4. Confirm each file

    POST .../uploads/complete with { "fileId": "..." }. SteadyLink checks that the stored size matches size (422 otherwise) and returns { "complete": true }. Safe to repeat.

  5. Finish

    POST .../submissions/{submission_id}/complete returns { "complete": true }, or 422 if a required file is still missing. The workspace is notified with a request.submitted event.

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#

POST/api/migration-scans
Requiresassets:write

Body

urlstringRequired
Site to scan. https:// is added when no scheme is given.
maxPagesintegerDefault 50
Pages to crawl, from 1 to 200.
curl
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 }'
202 AcceptedResponse
{
  "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#

GET/api/migration-scans
Requiresassets:read

Returns the 100 most recent scans, newest first.

Get a scan and its media#

GET/api/migration-scans/{scan_id}
Requiresassets:read

Returns the scan plus media, sorted by how often each file is referenced.

200 OKResponse
{
  "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#

POST/api/migration-scans/{scan_id}/cancel
Requiresassets:write

Stops a queued, running, or importing scan. Returns { "cancelled": true }, or 409 once it has finished.

Retry a failed scan#

POST/api/migration-scans/{scan_id}/retry
Requiresassets:write

Queues 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#

POST/api/migration-scans/{scan_id}/imports
Requiresassets:write

Body

mediaIdsuuid[]Required
From 1 to 200 media IDs from this scan. Each must be not broken and not yet imported.
bucketIduuidRequired
Destination bucket.
pathstringDefault website-import
Destination folder. Nested paths like website-import/images are allowed; empty, ., and .. segments are not.
curl
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" }'
202 AcceptedResponse
{ "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#

GET/api/migration-scans/{scan_id}/export/{format}
Requiresassets:read

format 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:

200 OKResponse
{
  "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#

GET/api/notifications/preferences
Signed-in session

Returns 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.

200 OKResponse
{
  "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#

PUT/api/notifications/preferences
Signed-in session

Saves 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.
modestringDefault immediate
immediate or digest.
enabledbooleanDefault true
Set false to stop this event for you.
curl
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 }'
200 OKResponse
{ "eventType": "request.submitted", "channels": ["email", "in_app"], "mode": "immediate", "enabled": true }

An unknown event, channel, or mode returns 422.

Read notifications#

GET/api/notifications
Signed-in session

Returns 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.

Next steps#