Skip to content
Slingdrop

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 acme just works. A name someone else owns returns 409 conflict.
  • Leave the name out (the CLI without --space, or POST /hosting/spaces with no slug) and you get your default space: your first one, or a new one named after the name you show others (or files when 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).
GET/hosting/spacesList your spaces
POST/hosting/spacesCreate a space, or get your default one
DELETE/hosting/spaces/{space}Delete a space and every file in it

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

terminal
# 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.json
PUT/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. inherit clears 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:

HostedObject
{
  "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.

terminal
# 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 flagWhat 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/.
--immutableCache for a year (for hashed file names).
--template <name>Apply an access template to these files, e.g. "Signed links".
--jsonPrint a { path: url } manifest instead of progress.
host ls [--prefix p]List hosted files.
host rm <path…>Delete hosted files.
host spacesList your spaces.
host usageStorage, 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.

route.ts
// 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 }
}
browser.ts
// 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`;
}
POST/hosting/{space}/upload-urlsCreate a signed upload URL
pathstring
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 with 400 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 w and h. Default scale-down (fit inside, never enlarge). cover fills the box and crops; pad fits inside and pads.
formatauto | avif | webp | jpeg | jpg | png
Output format. auto picks AVIF or WebP when the browser's Accept header allows it. Left out, PNG and WebP stay as they are and other images become JPEG.
index.html
<!-- 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#

FileCache-Control
Publicpublic, max-age=300
Public, uploaded with immutablepublic, max-age=31536000, immutable
Public, under a Public template with its own max agepublic, 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 immutable when their names change with their content (for example app.3f9c1e.js). An immutable file you replace keeps being served from caches for up to a year.
  • Range requests, HEAD, If-None-Match and CORS (Access-Control-Allow-Origin: * for public files) all work, so video seeking and fetch() 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#

manage.ts
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 }
GET/hosting/{space}/objects?prefix=&after=&limit=List files (up to 1,000 per page); returns objects and a next cursor
GET/hosting/{space}/objects/{path}One file's details
DELETE/hosting/{space}/objects/{path}Delete a file
GET/hosting/usageStorage, bandwidth and resizes this month

Limits#

FreeProTeam
Hosted storage1 GBWithin your 50 GB plan storageWithin your 250 GB plan storage
Bandwidth per month10 GB250 GB, shared with drops1 TB, shared with drops
Largest file25 MB5 GB50 GB
Image resizes per month1,0002,50010,000
Spaces320100

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.