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:

  1. Create โ€” POST /v1/files/{folderId} with the file's metadata. You get back a short-lived presigned upload URL and a file id.
  2. Upload โ€” PUT the raw bytes straight to that presigned URL.
  3. Confirm โ€” POST /v1/files/confirm-upload with the id to 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. Prefer curl only? The curl file upload page is the same flow as a shell recipe, with a jq one-liner and curl troubleshooting.

Step 1 ยท create
# Step 1 โ€” ask for a presigned upload URL
curl -X POST https://api.cdn22.net/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://<bucket>.s3.<region>.amazonaws.com/...&X-Amz-Signature=...",
#       "id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
#       "key": "<owner>/<folder>/report.pdf",
#       "private": false,
#       "duplicate": false
#     }
#   ]
# }
Step 2 ยท upload bytes
# 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://<bucket>.s3.<region>.amazonaws.com/...&X-Amz-Signature=..." \
  -H "Content-Type: application/pdf" \
  --data-binary @report.pdf
Step 3 ยท confirm
# Step 3 โ€” confirm the upload with the id from step 1.
curl -X POST https://api.cdn22.net/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.cdn22.net/<owner>/<folder>/report.pdf

Method 2 โ€” direct browser upload (backend mints the URL)

Never put a cdn22.net API key in browser, mobile, or public repo code. It is a long-lived secret: anyone who opens devtools or reads your bundle can list, upload and delete files on your account. Keep it on a server you control and hand the browser only the short-lived presigned URL from step 1.

The bytes still go straight from the page to storage โ€” your server never proxies them. It only issues two small JSON calls on the browser's behalf: create (step 1) and confirm (step 3). Any backend works โ€” a Next.js route handler, a Lambda, an Express route.

Backend ยท mints the presigned URL
// Your backend (Node 18+, any framework). The API key lives here and
// only here โ€” it is never sent to the browser.
const API = "https://api.cdn22.net/v1";
const API_KEY = process.env.CDN22_API_KEY;   // server env only
const FOLDER_ID = process.env.CDN22_FOLDER_ID;

// POST /api/upload-url  { name, size, type } -> { url, id, key }
export async function createUploadUrl({ name, size, type }) {
  // Authenticate the caller and validate name/size/type here โ€” this
  // endpoint mints upload credentials on your account.
  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, size, type }] }),
  });
  if (!res.ok) throw new Error(`create failed: ${res.status}`);
  const { urls } = await res.json();
  return urls[0]; // { url, id, key } โ€” short-lived, safe to hand to the browser
}

// POST /api/confirm-upload  { id } -> { ok: true }
export async function confirmUpload(id) {
  const res = await fetch(`${API}/files/confirm-upload`, {
    method: "POST",
    headers: {
      Authorization: API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ ids: [id] }),
  });
  if (!res.ok) throw new Error(`confirm failed: ${res.status}`);
  return { ok: true };
}

Then the page takes a File from an <input type="file"> and uploads it with no API key of its own:

Browser ยท fetch + File
// Browser โ€” no cdn22.net API key anywhere in this file. The page only
// ever holds a short-lived presigned URL minted by your backend.
async function uploadFile(file) {
  // 1. Ask YOUR backend for a presigned URL for this exact file.
  const res = await fetch("/api/upload-url", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name: file.name, size: file.size, type: file.type }),
  });
  const { url, id, key } = await res.json();

  // 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 via your backend (unconfirmed uploads are cleaned up).
  await fetch("/api/confirm-upload", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ id }),
  });

  // Public file โ†’ permanent CDN URL.
  return `https://cdn.cdn22.net/${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 ยท 18+
// 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.cdn22.net/v1";
const API_KEY = process.env.CDN22_API_KEY; // keep keys in env, never in code
const FOLDER_ID = process.env.CDN22_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.cdn22.net/${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.
Security note for browser uploads: that third point only holds if the API key stays server-side. Steps 1 and 3 hit /v1 with your long-lived key, so run them on your backend โ€” as in Method 2 โ€” and give the browser only the presigned URL. If a key ever does reach client code, revoke it in the dashboard and issue a new one.

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 key returned in step 1: https://cdn.cdn22.net/<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 size and type you send in step 1 are baked into the presigned URL. Send the real byte length and content type, and use the same Content-Type on the PUT.
  • Batch up to 100 files. filesMetadata accepts an array, and confirm-upload takes 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

cdn22.net 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.

Wiring this into a low-code internal tool? The Retool file upload & storage page maps the same three calls onto a Retool File Input and REST resource. More guides on the Developer Guides hub.

cdn22.net
Copyright ยฉ 2026
All rights reserved
ContactGuidesGlossaryStatusLegal