How to upload files via API (web โ CDN)
You have a file โ a PDF a user just picked, an image your app generated, a build artifact from CI โ and you want a permanent, public URL you can drop into a page, an email, or a database. This guide takes you from that file to a live CDN link with three API calls, and shows the exact same flow from curl, the browser, and Node.js.
No bucket to create, no IAM policies to wire up, no AWS console โ you get an API key and a folder id, and you're uploading. Files go out over a global CDN with edge locations, so the URL is fast everywhere. Prefer no code? The file-to-link generator does the same upload-file-to-link job from the dashboard โ drag, drop, copy the URL.
The upload flow in three calls
Every upload โ from anywhere โ is the same three steps:
- Create โ
POST /v1/files/{folderId}with the file's metadata. You get back a short-lived presigned upload URL and a fileid. - Upload โ
PUTthe raw bytes straight to that presigned URL. - Confirm โ
POST /v1/files/confirm-uploadwith theidto finalize. Uploads you never confirm are automatically cleaned up.
Why three calls instead of one big multipart POST? Because step 2 goes directly to storage. Your server (or your build script, or the browser tab) never has to proxy the file bytes through an API โ which is what makes large files and direct browser uploads cheap and fast. More on that below.
Authentication
Every call to /v1 (steps 1 and 3) authenticates with your API key in the Authorization header. Send the raw key โ there is no Bearer prefix:
Authorization: <YOUR_API_KEY>Step 2 (the PUT) needs no auth header at all โ the credentials are encoded in the presigned URL itself, which is what lets an untrusted client (like a browser) do the upload without ever seeing a long-lived secret.
Method 1 โ curl (the canonical flow)
The clearest way to see all three calls. Replace <FOLDER_ID> and <YOUR_API_KEY> with values from your dashboard.
# Step 1 โ ask for a presigned upload URL
curl -X POST https://api.files.link/v1/files/<FOLDER_ID> \
-H "Authorization: <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"filesMetadata": [
{ "name": "report.pdf", "size": 248173, "type": "application/pdf" }
]
}'
# Response
# {
# "success": true,
# "urls": [
# {
# "url": "https://upload.files.link/...&X-Signature=...",
# "id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
# "key": "<owner>/<folder>/report.pdf",
# "private": false,
# "duplicate": false
# }
# ]
# }# Step 2 โ PUT the raw bytes straight to the presigned URL.
# No Authorization header here โ the signature is baked into the URL.
curl -X PUT "https://upload.files.link/...&X-Signature=..." \
-H "Content-Type: application/pdf" \
--data-binary @report.pdf# Step 3 โ confirm the upload with the id from step 1.
curl -X POST https://api.files.link/v1/files/confirm-upload \
-H "Authorization: <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{ "ids": ["1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"] }'
# Your public file is now live at:
# https://cdn.files.link/<owner>/<folder>/report.pdfMethod 2 โ direct browser upload (no backend)
Take a File from an <input type="file"> and upload it straight from the page โ the bytes never pass through your server.
// Direct browser upload โ no backend, no proxying bytes through your server.
// Wire this to an <input type="file"> change event.
const API = "https://api.files.link/v1";
const API_KEY = "<YOUR_API_KEY>"; // see the security note below
const FOLDER_ID = "<FOLDER_ID>";
async function uploadFile(file) {
// 1. Request a presigned URL for this exact file.
const res = await fetch(`${API}/files/${FOLDER_ID}`, {
method: "POST",
headers: {
Authorization: API_KEY, // raw key, no "Bearer" prefix
"Content-Type": "application/json",
},
body: JSON.stringify({
filesMetadata: [
{ name: file.name, size: file.size, type: file.type },
],
}),
});
const { urls } = await res.json();
const { url, id, key } = urls[0];
// 2. PUT the File object straight to storage. The browser streams the
// bytes directly โ they never touch your server.
await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file,
});
// 3. Confirm so the file is finalized (unconfirmed uploads are cleaned up).
await fetch(`${API}/files/confirm-upload`, {
method: "POST",
headers: {
Authorization: API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ ids: [id] }),
});
// Public file โ permanent CDN URL.
return `https://cdn.files.link/${key}`;
}
// Usage:
document.querySelector("#file").addEventListener("change", async (e) => {
const cdnUrl = await uploadFile(e.target.files[0]);
console.log("Live at:", cdnUrl);
});Method 3 โ Node.js (server-side)
Same three steps from a backend or a CI job. Read the file, request a URL, PUT the bytes, confirm. Keep your API key in an environment variable.
// Node.js (server-side) upload. Requires Node 18+ for global fetch.
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
const API = "https://api.files.link/v1";
const API_KEY = process.env.FILESLINK_API_KEY; // keep keys in env, never in code
const FOLDER_ID = process.env.FILESLINK_FOLDER_ID;
export async function upload(path, contentType) {
const bytes = await readFile(path);
// 1. Presigned URL
const createRes = await fetch(`${API}/files/${FOLDER_ID}`, {
method: "POST",
headers: {
Authorization: API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
filesMetadata: [
{ name: basename(path), size: bytes.length, type: contentType },
],
}),
});
if (!createRes.ok) throw new Error(`create failed: ${createRes.status}`);
const { urls } = await createRes.json();
const { url, id, key } = urls[0];
// 2. PUT the bytes
const putRes = await fetch(url, {
method: "PUT",
headers: { "Content-Type": contentType },
body: bytes,
});
if (!putRes.ok) throw new Error(`put failed: ${putRes.status}`);
// 3. Confirm
const confirmRes = await fetch(`${API}/files/confirm-upload`, {
method: "POST",
headers: {
Authorization: API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ ids: [id] }),
});
if (!confirmRes.ok) throw new Error(`confirm failed: ${confirmRes.status}`);
return `https://cdn.files.link/${key}`;
}
// const cdnUrl = await upload("./report.pdf", "application/pdf");Why direct-from-the-browser uploads matter
Because step 2 is a plain PUT to a presigned URL, the file goes from the user's device straight to storage. That has real benefits:
- No proxy server. You don't need an upload endpoint that buffers gigabytes of user data โ your backend only handles two small JSON calls (create + confirm).
- Cheaper and faster. Bytes take the shortest path. You're not paying to ingest a 2ย GB video into your app server just to forward it on.
- No long-lived secret in the client. The browser only ever sees a short-lived presigned URL, not a storage credential.
Public vs private files
Whether a file is public or private follows its folder (you can override per file at create time). The difference is how you read it back:
- Public files are served over the global CDN at a stable URL โ combine the CDN base with the
keyreturned in step 1:https://cdn.files.link/<key>. - Private files aren't publicly reachable. You request a signed URL when you need to serve one; it expires after 10 minutes, so generate it on demand rather than caching it.
Notes & gotchas
- Metadata must match. The
sizeandtypeyou send in step 1 are baked into the presigned URL. Send the real byte length and content type, and use the sameContent-Typeon the PUT. - Batch up to 100 files.
filesMetadataaccepts an array, andconfirm-uploadtakes up to 100 ids โ upload many files, then confirm them all in one call. - Confirm, or it disappears. A file that's created and PUT but never confirmed is treated as abandoned and garbage-collected. Always run step 3.
- Don't lose the window. Presigned URLs are short-lived โ request, PUT, and confirm in one pass rather than minting URLs ahead of time.
- Really large files? For multi-gigabyte uploads there's a multipart variant of the same flow (initiate โ upload parts โ complete). The three-call single-PUT flow here is the right default for everything else.
Get your first CDN URL
files.link is prepaid and pay-as-you-go: create an account, add a payment method, grab an API key, and run the three calls above. You pay only for what you store and serve.
More guides on the Developer Guides hub.