File hosting
File hosting (CDN)
Host app assets and your users' uploads on stable CDN URLs, with image resizing and caching.
File hosting gives every file a stable URL on Slingdrop's CDN: https://cdn.slingdropcontent.my/<space>/<path>. Use it for app assets (logos, fonts, video, build output) and for files your own users upload. It's part of every signed-in plan, and Free plan API keys can use every hosting endpoint.
Hosted files are public until you say otherwise. To protect some or all of them with signed links, your app's sign-in or an approval webhook, see Access rules.
Spaces#
Every hosted file belongs to a space: a name that becomes the first part of its URL. Space names are 3 to 40 lowercase letters, numbers and dashes, unique across Slingdrop. A few names (like api, cdn and assets) are reserved.
- Uploading to a name that's free creates the space for you, so
slingdrop host ./assets --space acmejust works. A name someone else owns returns409 conflict. - Leave the name out (the CLI without
--space, orPOST /hosting/spaceswith noslug) and you get your default space: your first one, or a new one named after the name you show others (orfileswhen you hide it), plus a few random characters. - Deleting a space deletes every file in it. Each plan allows a number of spaces (see limits).
/hosting/spacesList your spaces/hosting/spacesCreate a space, or get your default one/hosting/spaces/{space}Delete a space and every file in itUpload files#
Upload with the CLI, the REST API or the SDK. Uploading to a path that already exists replaces the file; its URL stays the same. Paths are up to 512 characters, can't contain .. or control characters, and a leading slash is ignored.
# Sign in once (opens your browser)
npx slingdrop login
# Upload a folder: every file gets a URL
npx slingdrop host ./public/assets --space acme
# Content-hashed build output: cache for a year
npx slingdrop host ./dist/assets --space acme --prefix v42 --immutable
# A JSON manifest { "path": "url" } for your build
npx slingdrop host ./public/assets --space acme --json > assets.jsoncurl -X PUT https://api.slingdrop.my/v1/hosting/acme/objects/img/logo.png \
-H "Authorization: Bearer $SLINGDROP_API_KEY" \
-H "Content-Type: image/png" \
--data-binary @logo.png
# 201 → { "data": { "path": "img/logo.png",
# "url": "https://cdn.slingdropcontent.my/acme/img/logo.png", … } }import { openAsBlob } from "node:fs";
import { Slingdrop } from "@slingdrop/sdk";
const sd = new Slingdrop({ apiKey: process.env.SLINGDROP_API_KEY });
const logo = await sd.hosting.put("acme", "img/logo.png", await openAsBlob("logo.png"), {
contentType: "image/png",
});
console.log(logo.url); // https://cdn.slingdropcontent.my/acme/img/logo.png
// Hashed file names never change, so let browsers keep them for a year
await sd.hosting.put("acme", "assets/app.3f9c1e.js", await openAsBlob("dist/app.3f9c1e.js"), { immutable: true });/hosting/{space}/objects/{path}Upload a file (body = the bytes, Content-Type = its type)immutablequery: 1- Cache the URL for a year. Use it only for content-hashed names, because a replaced file would stay cached.
templatequery: string- An access template (id or name) for this file.
inheritclears an override so the space's rules decide.
If you leave out Content-Type (or send application/octet-stream), the type is worked out from the file extension. The response is the hosted file:
{
"path": "img/logo.png",
"url": "https://cdn.slingdropcontent.my/acme/img/logo.png",
"size": 18342,
"contentType": "image/png",
"immutable": false,
"sha256": "9f2c…",
"blocked": false,
"templateId": null,
"updatedAt": "2026-10-21T09:12:44.000Z"
}Large files (multipart)#
A single PUT carries up to 95 MB. Bigger files (up to your plan's largest file) are sent in 50 MB parts. The SDK's hosting.put and the CLI switch to multipart on their own; over REST it's three calls. The upload token is valid for 24 hours.
# 1. Start: say where the file goes and how big it is
curl https://api.slingdrop.my/v1/hosting/acme/multipart \
-H "Authorization: Bearer $SLINGDROP_API_KEY" -H "content-type: application/json" \
-d '{"path":"video/launch.mp4","size":734003200,"contentType":"video/mp4"}'
# 201 → { "token": "T", "partSize": 52428800, "parts": 14 }
# 2. Send each part (1-based), partSize bytes each except the last
curl -X PUT https://api.slingdrop.my/v1/hosting/acme/multipart/parts/1 \
-H "Authorization: Bearer $SLINGDROP_API_KEY" -H "x-upload-token: T" \
--data-binary @part-01
# → { "partNumber": 1, "etag": "…" }
# 3. Finish with every part's etag
curl https://api.slingdrop.my/v1/hosting/acme/multipart/complete \
-H "Authorization: Bearer $SLINGDROP_API_KEY" -H "content-type: application/json" \
-d '{"token":"T","parts":[{"partNumber":1,"etag":"…"}, …]}'CLI options#
| Command or flag | What it does |
|---|---|
host <files or folders…> | Upload and print each file's URL. A folder's contents keep their layout (.git, node_modules and .DS_Store are skipped). |
--space <name> | The space to upload to. Created if the name is free; your default space when left out. |
--prefix <path> | Put the files under this path, e.g. v2/. |
--immutable | Cache for a year (for hashed file names). |
--template <name> | Apply an access template to these files, e.g. "Signed links". |
--json | Print a { path: url } manifest instead of progress. |
host ls [--prefix p] | List hosted files. |
host rm <path…> | Delete hosted files. |
host spaces | List your spaces. |
host usage | Storage, bandwidth and image resizes this month. |
The CLI uploads four files at a time and exits with status 1 if any file failed. Every command is in the CLI reference.
Your users' uploads#
To let people using your app upload files (avatars, attachments), don't put your API key in the browser. Instead, your server creates a short-lived signed upload URL after its own login check, and the browser sends the file straight to it. No API key is needed for that request, and any origin may call it.
// app/api/avatar-upload/route.ts — your server decides who may upload, and where
import { Slingdrop } from "@slingdrop/sdk";
import { getCurrentUser } from "@/lib/auth"; // your own login check
const sd = new Slingdrop({ apiKey: process.env.SLINGDROP_API_KEY });
export async function POST() {
const user = await getCurrentUser();
if (!user) return new Response("Sign in first", { status: 401 });
const upload = await sd.hosting.createUploadUrl("acme", {
prefix: `avatars/${user.id}`,
filename: "avatar.png",
contentType: "image/*", // an exact type, or a family
maxBytes: 5_000_000,
expiresIn: 600, // seconds: 60 to 7 days, 1 hour by default
});
return Response.json(upload); // { uploadUrl, method, path, url, maxBytes, contentType, expiresAt }
}curl https://api.slingdrop.my/v1/hosting/acme/upload-urls \
-H "Authorization: Bearer $SLINGDROP_API_KEY" -H "content-type: application/json" \
-d '{"prefix":"avatars/user_123","filename":"avatar.png","contentType":"image/*","maxBytes":5000000}'
# 201 → { "uploadUrl": "https://slingdrop.my/api/hosting/uploads/…", "method": "PUT",
# "path": "avatars/user_123/k3j9x0a1b2c4-avatar.png",
# "url": "https://cdn.slingdropcontent.my/acme/avatars/user_123/k3j9x0a1b2c4-avatar.png",
# "maxBytes": 5000000, "contentType": "image/*", "expiresAt": "…" }// In the browser: the file goes straight to Slingdrop, not through your server
async function uploadAvatar(file: File) {
const { uploadUrl, url } = await fetch("/api/avatar-upload", { method: "POST" }).then((r) => r.json());
const res = await fetch(uploadUrl, {
method: "PUT",
headers: { "content-type": file.type },
body: file,
});
if (!res.ok) throw new Error((await res.json()).error); // e.g. "This upload must be image/*."
return `${url}?w=256&h=256&fit=cover&format=auto`;
}/hosting/{space}/upload-urlsCreate a signed upload URLpathstring- The exact path to write. Leave it out to get a random path under
prefix. prefixstring- Folder for a random path. Defaults to
uploads/. filenamestring- Ends the random path, after a random id (characters other than letters, digits,
.,_and-become dashes). contentTypestring- The type the upload must have: exact (
image/png) or a family (image/*). Anything else is refused with400 invalid_type. maxBytesinteger- Largest file accepted. Capped at your plan's largest file and 95 MB.
expiresIninteger- Seconds the URL works: 60 to 604,800 (7 days). Default 3,600.
What an upload URL allows
One URL writes one path, until it expires, and can be reused until then (a second upload replaces the first). Uploads count against your storage, are rate limited per IP address, and answer 201 with the hosted file, or JSON { "error", "code" } on failure: 403 expired, 413 too_large, 400 invalid_type or 429 rate_limited.
Image resizing#
Add query parameters to any PNG, JPEG, GIF, WebP or AVIF URL to get a resized copy. Each new size is made once, cached at the edge for that version of the file, and counts as one resize against your monthly allowance. Asking for the same size again is free.
winteger, 1–4096- Width in pixels.
hinteger, 1–4096- Height in pixels.
fitscale-down | contain | cover | crop | pad- How to fit both
wandh. Defaultscale-down(fit inside, never enlarge).coverfills the box and crops;padfits inside and pads. formatauto | avif | webp | jpeg | jpg | png- Output format.
autopicks AVIF or WebP when the browser'sAcceptheader allows it. Left out, PNG and WebP stay as they are and other images become JPEG.
<!-- 400 px wide, WebP or AVIF if the browser takes it -->
<img src="https://cdn.slingdropcontent.my/acme/img/hero.jpg?w=400&format=auto" alt="" />
<!-- A square thumbnail, cropped to fill -->
<img src="https://cdn.slingdropcontent.my/acme/img/hero.jpg?w=160&h=160&fit=cover" alt="" />
<!-- Responsive -->
<img
src="https://cdn.slingdropcontent.my/acme/img/hero.jpg?w=800&format=auto"
srcset="https://cdn.slingdropcontent.my/acme/img/hero.jpg?w=400&format=auto 400w,
https://cdn.slingdropcontent.my/acme/img/hero.jpg?w=800&format=auto 800w,
https://cdn.slingdropcontent.my/acme/img/hero.jpg?w=1600&format=auto 1600w"
sizes="(max-width: 600px) 100vw, 800px"
alt=""
/>Resizing only happens when at least one of w, h or format is set, and only for those image types; other files are served as they are. Resized images are encoded at quality 82. Access rules are checked before a resized copy is served, so protected images stay protected at every size.
When a month's resizes run out, new sizes answer 429. Sizes already made keep working, and the original file (without resize options) is always served.
Caching#
| File | Cache-Control |
|---|---|
| Public | public, max-age=300 |
| Public, uploaded with immutable | public, max-age=31536000, immutable |
| Public, under a Public template with its own max age | public, max-age=<template max age> |
| Protected (signed, sign-in or webhook) | private, max-age=<template max age>, never longer than a signed link lasts |
- Replacing a file changes its
ETag, so browsers that revalidate get the new one. Until then a public file can stay cached for up to five minutes. - Only mark files
immutablewhen their names change with their content (for exampleapp.3f9c1e.js). An immutable file you replace keeps being served from caches for up to a year. - Range requests,
HEAD,If-None-Matchand CORS (Access-Control-Allow-Origin: *for public files) all work, so video seeking andfetch()from any site are fine.
What gets served inline#
Hosted files are served from a separate domain with a sandboxing Content-Security-Policy. Images, video, audio, fonts, PDFs, JSON and plain text open in the browser. HTML, JavaScript, SVG, archives and everything else download as attachments instead of running. Every upload is checked against known malware; a blocked file answers 451.
Listing, deleting and usage#
const spaces = await sd.hosting.spaces(); // [{ slug, url, objectCount, bytesStored, … }]
const space = await sd.hosting.createSpace("acme"); // or createSpace() for your default space
// List files, 1,000 at a time
let after: string | undefined;
do {
const page = await sd.hosting.list("acme", { prefix: "img/", after, limit: 1000 });
for (const o of page.objects) console.log(o.url, o.size);
after = page.next ?? undefined;
} while (after);
await sd.hosting.delete("acme", "img/old-logo.png");
const usage = await sd.hosting.usage(); // { month, storage, bandwidth, transforms, maxFileBytes }/hosting/{space}/objects?prefix=&after=&limit=List files (up to 1,000 per page); returns objects and a next cursor/hosting/{space}/objects/{path}One file's details/hosting/{space}/objects/{path}Delete a file/hosting/usageStorage, bandwidth and resizes this monthLimits#
| Free | Pro | Team | |
|---|---|---|---|
| Hosted storage | 1 GB | Within your 50 GB plan storage | Within your 250 GB plan storage |
| Bandwidth per month | 10 GB | 250 GB, shared with drops | 1 TB, shared with drops |
| Largest file | 25 MB | 5 GB | 50 GB |
| Image resizes per month | 1,000 | 2,500 | 10,000 |
| Spaces | 3 | 20 | 100 |
Hosted files always count towards your plan's storage too. You get one email a month when you pass 80% of your bandwidth or resizes. When a month's bandwidth runs out, files answer 429 until the next month or an upgrade. Signing in is required to host: anonymous drops can't. See Limits for every plan limit.