dropper

Docs
Documentation

    Overview

    REST API

    CLI

    Coding agents

    MCP server

REST API

The drop, per-file, stats, trash, export, gallery-thumbnail and who-am-I routes under /api take a personal API key; the handle rename, the connected-apps routes and the admin routes are session-only; and the /d routes are public — that is the point of a drop — as is GET /api/version, the deployment’s version stamp.

Authentication

Every /api/* drop request carries a bearer credential in an Authorization header — a personal API key, or an OAuth access token issued to an MCP connector or to the CLI's dropper login. Either one is tied to your account: the API scopes everything to your own drops, served at /d/<your-handle>/<slug>/. Minting one takes a minute:

  1. Sign in to the dashboard with Authelia.
  2. Open the API Keys page and create a key — it is shown exactly once, so copy it right there.
  3. Send it as a bearer on every request (the examples below keep it in the DROPPER_TOKEN env var, same as the CLI):
Authorization: Bearer $DROPPER_TOKEN

Revoke a key on the same page and it stops working immediately. In the browser, the dashboard itself skips keys entirely and rides its session cookie. Admin accounts get two extra query params — all=1 on list and owner=<handle> on the per-drop routes — documented per endpoint below; non-admin use gets 403. Neither applies to the trash, export, labels and stats summary routes: those are always your own account, for admins too. GET /api/me answers any of these credentials, the dashboard session included, with your account and says which one answered.

The account routes — PATCH /api/profile and /api/connections — are session-only: they change your handle and disconnect apps from the dashboard, and refuse every bearer, so neither an API key nor a connector token can rename you or revoke a connection.

The admin routes — /api/admin/users and /api/admin/users/<handle>, the data behind the Admin › Users page — are session-only as well and answer 403 for any session that is not an admin: they list every account with its usage and suspend or unsuspend one, so a leaked API key or connector token can neither enumerate accounts nor suspend anyone. A suspended account gets 403 account suspended on every route, its credentials otherwise valid.

A machine-readable OpenAPI 3.1 spec is served at /openapi.json — import it into Postman, Insomnia, or Bruno to try the API interactively, or feed it to a client generator.

POST
/api/drops
API key

Create a drop in your namespace

Upload a single .html page, a single .md Markdown page, or a .zip of a static site. A .md is stored as index.md and its URL renders as a styled page for browsers while ?raw=1 (and curl) still gets the source. Zips are extracted server-side and served S3-style: the entry page at the drop root, assets and nested pages beside it. The drop lands in your namespace — its public URL is /d/<your-handle>/<slug>/ and slugs only collide with your own.

Multipart form fields
NameTypeRequiredDescription
filefile
required

An .html page, a .md Markdown page or a .zip of a static site (50 MB max upload). The kind comes from this filename; a .md over the 1 MB render cap is still accepted and always serves its source.

slugstringoptional

Custom slug (lowercase letters, digits, hyphens; max 64 chars). Random 8 chars when omitted.

namestringoptional

Display name (trimmed; max 256 chars) — shown in the drops list and used as the title of a rendered Markdown page (a trailing .md is dropped). Blank or omitted falls back to the uploaded filename. Never affects the kind.

descriptionstringoptional

Short description (trimmed; max 200 chars) — public: the gallery shows it when the drop is public. Blank or omitted means none.

labelsstringoptional

Label paths, comma-separated: acme/site,personal. A path is at most 3 names joined with /, each name lowercase letters, digits and hyphens (1–32 chars, starting with a letter or digit); every path is trimmed and empty entries are skipped, nothing is otherwise normalised. A label that does not exist yet is created along with its missing ancestors (a new top-level label gets the default colour, blue), and a label sent together with one of its ancestors keeps only the descendant. The field carries at most 20 paths, so a drop holds at most 20 labels. A malformed path, one deeper than 3 levels, or more than 20 paths is a 400 before anything is written. Blank or omitted means no labels.

passwordstringoptional

Access code (trimmed; max 256 chars). Blank means unprotected.

public"true" | "false"optional

Gallery visibility. Parsed strictly: exactly the string "true" or "false" — any other value (blank included) is a 400. Omitted means private. Discoverability only — it never changes /d/ URL access or access-code behavior.

Responses

201

Drop created — returns the drop info plus its public url

400

Missing file, wrong file type, a name over 256 chars, a description over 200 chars, a malformed or too-deep label path, more than 20 label paths, invalid slug, invalid public value, or invalid zip

401

Missing or invalid API key

409

Slug already taken in your namespace

413

Upload over 50 MB, extracted content over 200 MB, or more than 2000 files

Example
curl -X POST https://dropp.sh/api/drops \
  -H "Authorization: Bearer $DROPPER_TOKEN" \
  -F file=@page.html -F slug=demo \
  -F description='Launch page draft' -F labels=acme/site
response
{
  "slug": "demo",
  "owner": "alice",
  "name": "page.html",
  "description": "Launch page draft",
  "createdAt": "2026-08-27T12:00:00.000Z",
  "kind": "html",
  "files": 1,
  "bytes": 1024,
  "protected": false,
  "public": false,
  "archived": false,
  "labels": ["acme/site"],
  "url": "https://dropp.sh/d/alice/demo/"
}
GET
/api/drops
API key

List your drops, newest first

Your drops, newest first — same shape as create, including owner and url. The protected flag signals password protection (hashes are never returned); the public flag signals gallery visibility; the archived flag marks drops retired from serving, which stay in this listing (they are yours) but 404 at their public URL. Every row carries description when one is set; your own rows also carry labels, the drop’s label paths ([] when none), which gallery and all=1 rows never do — and label=<path> narrows your own listing to the drops carrying that label or any of its sublabels. On your own drops a password field carries the access code in the clear: codes are sealed at rest with a key derived from the server secret and returned to the owner only, so the field never appears on the gallery listing (public=1), the admin all=1 listing, or an admin owner= view — and a code set before drops started storing that seal stays unreadable until you set it again. With views=1 your own rows also carry views30d, each drop’s views over the last 30 UTC days (null when untracked or when statistics are unavailable, which never fails the listing); without it, and on the gallery and all=1 rows, the field is absent. This route does not read the owner query param (one given is ignored) — admins list other users with all=1 instead.

Query parameters
NameTypeDescription
all"1"

Admin only: exactly all=1 lists every user’s drops; any other value is ignored and lists your own. Non-admins sending all=1 get 403. The owner query param is not read on this route.

public"1"

Gallery listing: exactly public=1 lists every user’s gallery-public drops (archived drops are excluded) — open to any authenticated caller, no admin role needed. Takes precedence over all=1 when both are given (the reply stays narrowed to public drops and never 403s). Any other value is ignored and normal scoping applies.

views"1"

Your own listing only: exactly views=1 adds views30d to every row. Any other value is ignored, and so is views=1 alongside public=1 or all=1.

labelstring

Your own listing only: just the drops carrying this label path or any of its sublabels — label=acme lists drops labelled acme/site too. Matched exactly, never normalised. An unknown path lists nothing (an empty drops array); an empty or malformed value, or a path deeper than 3 levels, is a 400. Ignored alongside public=1 or all=1.

Responses

200

Your drops (for admins with all=1, everyone’s; with public=1, every user’s gallery-public drops) — the same drop shape as create, each with owner and public url, plus views30d on your own listing with views=1

400

An empty, malformed or too-deep label filter on your own listing

401

Missing or invalid API key

403

all=1 without the admin role (public=1 never 403s)

Example
curl https://dropp.sh/api/drops \
  -H "Authorization: Bearer $DROPPER_TOKEN"
# just acme and its sublabels:
curl "https://dropp.sh/api/drops?label=acme" \
  -H "Authorization: Bearer $DROPPER_TOKEN"
response
{
  "drops": [
    {
      "slug": "demo",
      "owner": "alice",
      "name": "page.html",
      "description": "Launch page draft",
      "createdAt": "2026-08-27T12:00:00.000Z",
      "kind": "html",
      "files": 1,
      "bytes": 1024,
      "protected": false,
      "public": false,
      "archived": false,
      "labels": ["acme/site"],
      "url": "https://dropp.sh/d/alice/demo/"
    }
  ]
}
PATCH
/api/drops/{slug}
API key

Edit a drop — rename, describe, label, change protection, toggle gallery visibility, archive, or replace content

Every field is optional and combinable: rename the slug, change the display name, set or clear the description, replace or clear the labels, set or clear the access code, toggle gallery visibility, archive or unarchive, or replace the content entirely. Changing or clearing the password revokes every viewer unlock cookie; replacing content keeps createdAt, the display name, the description, the labels, the password, and the public and archived flags. Only your own drops resolve — another user’s slug is a 404.

Path parameters
NameTypeDescription
slugstring

Slug of the drop to edit or delete, within the target namespace

Query parameters
NameTypeDescription
ownerstring

Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400.

Multipart form fields
NameTypeRequiredDescription
slugstringoptional

New slug (rename); the public URL changes.

namestringoptional

New display name (trimmed; max 256 chars) — shown in the drops list and used as the title of a rendered Markdown page (a trailing .md is dropped) — with or without a replacement file; a display-name rename alone is a valid update. Blank or omitted leaves it unchanged; a replacement file never renames the drop.

descriptionstringoptional

New description (trimmed; max 200 chars). Omitted leaves it unchanged; blank clears it — unlike name, where blank leaves the name as it is.

labelsstringoptional

Label paths, comma-separated: acme/site,personal. A path is at most 3 names joined with /, each name lowercase letters, digits and hyphens (1–32 chars, starting with a letter or digit); every path is trimmed and empty entries are skipped, nothing is otherwise normalised. A label that does not exist yet is created along with its missing ancestors (a new top-level label gets the default colour, blue), and a label sent together with one of its ancestors keeps only the descendant. The field carries at most 20 paths, so a drop holds at most 20 labels. A malformed path, one deeper than 3 levels, or more than 20 paths is a 400 before anything is written. The paths replace the drop’s whole set: blank clears every label, omitted leaves them unchanged. With owner= (admin) the paths resolve in that user’s labels, and missing ones are created there; the reply is the public shape, so it carries no labels.

passwordstringoptional

New access code; an empty string clears protection.

public"true" | "false"optional

Gallery visibility. Parsed strictly: exactly the string "true" or "false" — any other value (blank included) is a 400. Omitted leaves it unchanged.

archived"true" | "false"optional

"true" retires the drop from serving — its /d/ URL and thumbnail 404 and it leaves the gallery, while the slug stays reserved and your own listing and file read-back keep working; "false" brings it back. Parsed strictly like public — any other value (blank included) is a 400. Omitted leaves it unchanged.

filefileoptional

Replacement content: an .html page, a .md Markdown page or a .zip of a static site. The new kind comes from this filename; createdAt, the display name, the description, the labels and the access code survive.

Responses

200

Updated drop info plus its public url

400

No fields given, a name over 256 chars, a description over 200 chars, a malformed or too-deep label path, more than 20 label paths, wrong file type, invalid slug, invalid public or archived value, invalid zip, or invalid owner handle

401

Missing or invalid API key

403

owner param without the admin role

404

No drop with that slug in the target namespace

409

New slug already taken in the target namespace

413

Replacement content over the limits

Example
curl -X PATCH https://dropp.sh/api/drops/demo \
  -H "Authorization: Bearer $DROPPER_TOKEN" \
  -F slug=demo2 -F password=hunter2
# retire it from serving without giving up the slug:
curl -X PATCH https://dropp.sh/api/drops/demo2 \
  -H "Authorization: Bearer $DROPPER_TOKEN" -F archived=true
# replace its labels; a blank description clears it:
curl -X PATCH https://dropp.sh/api/drops/demo2 \
  -H "Authorization: Bearer $DROPPER_TOKEN" \
  -F labels=acme/site,personal -F description=
DELETE
/api/drops/{slug}
API key

Delete a drop — recoverable for 30 days by default

Deleting moves the drop to your trash: the public URL stops working at once and the slug is free to reuse immediately, but the content — access code, gallery and archived flags included — stays restorable through /api/trash for 30 days. The reply names the trash id to restore it by. Pass permanent=1 for the old behavior: content and view statistics gone outright, nothing in the trash, no undo (a trashed drop keeps its statistics until it is purged or swept). Either way the thumbnail is dropped. Only your own drops resolve — another user’s slug is a 404; an admin using owner= puts the entry in that owner’s trash, not their own.

Path parameters
NameTypeDescription
slugstring

Slug of the drop to edit or delete, within the target namespace

Query parameters
NameTypeDescription
permanent"1"

Exactly permanent=1 deletes the content outright, with nothing left in the trash; any other value (blank included) is ignored and the delete is the recoverable one.

ownerstring

Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400.

Responses

200

Moved to the trash (the default, recoverable path)

204

Deleted outright (permanent=1)

400

Invalid owner handle

401

Missing or invalid API key

403

owner param without the admin role

404

No drop with that slug in the target namespace

Example
curl -X DELETE https://dropp.sh/api/drops/demo \
  -H "Authorization: Bearer $DROPPER_TOKEN"
# no take-backs:
curl -X DELETE "https://dropp.sh/api/drops/demo?permanent=1" \
  -H "Authorization: Bearer $DROPPER_TOKEN"
response
{
  "status": "trashed",
  "trashId": "demo.1756598400000",
  "slug": "demo",
  "expiresAt": "2026-09-30T12:00:00.000Z"
}
GET
/api/drops/{slug}/files
API key

List the files inside one of your drops

Authenticated read-back: enumerates every file in one of your drops with its size and content type — works on protected and archived drops without a viewer unlock. The drop-root meta.json is not listed.

Path parameters
NameTypeDescription
slugstring

The drop slug

Query parameters
NameTypeDescription
ownerstring

Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400.

Responses

200

Files in the drop, sorted by path

400

Invalid owner handle

401

Missing or invalid API key

403

owner param without the admin role

404

No drop with that slug in the target namespace

Example
curl https://dropp.sh/api/drops/demo/files \
  -H "Authorization: Bearer $DROPPER_TOKEN"
response
{
  "files": [
    { "path": "assets/app.css", "bytes": 14, "contentType": "text/css; charset=utf-8" },
    { "path": "index.html", "bytes": 1024, "contentType": "text/html; charset=utf-8" }
  ]
}
GET
/api/drops/{slug}/files/{path}
API key

Read one file from one of your drops

Authenticated read-back of raw file content with its content type — the way to fetch current content before replacing it via PUT …/files/{path} (one file) or PATCH (the whole drop). Works on protected drops without a viewer unlock; never publicly cacheable.

Path parameters
NameTypeDescription
slugstring

The drop slug

pathstring

File path inside the drop, e.g. index.html or assets/app.css

Query parameters
NameTypeDescription
ownerstring

Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400.

Responses

200

The file, with its content type

400

Invalid owner handle

401

Missing or invalid API key

403

owner param without the admin role

404

No drop, no such file, or a reserved/unsafe path

Example
curl https://dropp.sh/api/drops/demo/files/index.html \
  -H "Authorization: Bearer $DROPPER_TOKEN"
PUT
/api/drops/{slug}/files/{path}
API key

Write one file into one of your drops

Creates or replaces a single file inside an existing drop — the way to add an image, a stylesheet or a page without re-uploading the whole site (PATCH with a file replaces everything). The drop itself is never created here: an unknown, trashed or another user’s slug is a 404. Two modes, chosen by the from query param and never by sniffing the body. Without it, the raw request body is the file’s bytes (curl -T file.png … does exactly that): it is streamed to disk with the 20 MB per-file cap enforced while reading, and an empty body writes a zero-byte file. With ?from=<url> the server downloads the URL into the drop instead, so image bytes never pass through the client; the request body must then be empty (any byte is a 400). The URL must be a public https:// address — loopback, private, link-local and IP-literal hosts, localhost, .local and .internal names and single-label hostnames are refused with a 400 before any connection is made; DNS is then resolved once, every answer re-checked, and the connection pinned to the approved address. In fetch mode the body check and the URL validation run before the drop lookup, so a blocked URL is the same 400 whether or not the drop exists; a valid URL against a missing drop is a 404 and the upstream is never contacted. Only a 200 is accepted: redirects are never followed, and a redirect, any other status, a timeout (one 15 s budget from DNS to the last byte), a connection failure, an empty body or a compressed (content-encoded) body is a 502 that names the upstream status but never the URL. The fetched content-type is not validated — the served type comes from the path’s extension, so name the file accordingly. Every write recomputes the drop’s files and bytes from disk, stamps updatedAt and re-renders the thumbnail; kind flips from html to zip once the drop holds more than one file and never flips back; name, description, labels, password, public and archived are untouched, and archived drops accept writes; the drop in the response never carries the access code or the labels (unlike PATCH on the same drop). The bytes are staged and renamed into place, so /d/ never serves a torn file — but a replaced file can be served stale for up to 60 seconds by the viewer’s own browser (/d/ responses are private, max-age=60, so no CDN holds them). After the write the drop must still fit 200 MB and 2000 files (a replaced file’s old size is counted out first). Writes and deletes to one drop are serialised with each other and with renames, edits and deletes of that drop and restores into its slug, so none of them can interleave.

Path parameters
NameTypeDescription
slugstring

The drop slug

pathstring

File path inside the drop, e.g. img/photo.jpg — parent directories are created. Rejected (400): traversal, empty or dot segments, a trailing slash, control characters, a segment over 255 bytes, a path over 1024 bytes, and the drop-root meta.json in any spelling. A literal .. segment is normalised away by the router before the handler and reads as a plain 404; the 400 covers the encoded forms that reach it — nothing is ever written outside the drop.

Query parameters
NameTypeDescription
fromURL

Server-side fetch mode: a public https:// URL the server downloads into the drop, no request body alongside it. Same 20 MB per-file cap; redirects are not followed. Percent-encode it like any query value (+ as %2B) — a literal + reads as a space. curl -X PUT -G --data-urlencode "from=<url>" <files url> does the encoding: -G puts the value in the query string, -X PUT keeps the request a write.

ownerstring

Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400.

Raw request body (optional)
NameTypeRequiredDescription
bodybytesoptional

The file’s content as-is (raw-body mode — no from param), any content type; the served type comes from the path’s extension. 20 MB max. Empty writes a zero-byte file; a body that ends early is a 400.

Responses

200

An existing file at the path was replaced

201

A new file was created at the path

400

Unsafe or reserved path (traversal, empty or dot segments, trailing slash, control characters, a segment over 255 bytes or a path over 1024 bytes, drop-root meta.json), a request body alongside ?from=, a raw request body that ended early, an invalid or blocked from URL, or an invalid owner handle

401

Missing or invalid API key

403

owner param without the admin role

404

No drop with that slug in the target namespace (never created implicitly), or no path given

409

The path is an existing directory, an ancestor segment is an existing file, or a symbolic link sits on the path (never followed)

413

File over 20 MB (raw body or fetched — a declared content-length over the cap is refused before the body), or the drop would exceed 200 MB / 2000 files

502

?from= upstream failure: a redirect (never followed), a non-200 status, a timeout, a connection failure, an empty body or a compressed (content-encoded) body (point from at a URL that serves the raw bytes) — the message carries the upstream status, never the URL

Example
# raw body: the file is the request body (curl -T sends a PUT)
curl -T photo.png https://dropp.sh/api/drops/demo/files/img/photo.png \
  -H "Authorization: Bearer $DROPPER_TOKEN"
# server-side fetch: no body, the source URL rides in ?from= (quote it — the URL has a query string)
curl -X PUT "https://dropp.sh/api/drops/demo/files/img/photo.jpg?from=https://example.com/photo.jpg" \
  -H "Authorization: Bearer $DROPPER_TOKEN"
response
{
  "file": { "path": "img/photo.png", "bytes": 48213, "contentType": "image/png" },
  "drop": {
    "slug": "demo",
    "owner": "alice",
    "name": "page.html",
    "description": "Launch page draft",
    "createdAt": "2026-08-27T12:00:00.000Z",
    "updatedAt": "2026-09-02T09:30:00.000Z",
    "kind": "zip",
    "files": 2,
    "bytes": 49237,
    "protected": false,
    "public": false,
    "archived": false,
    "url": "https://dropp.sh/d/alice/demo/"
  }
}
DELETE
/api/drops/{slug}/files/{path}
API key

Delete one file from one of your drops

Removes a single file and prunes the directories it leaves empty, up to but not including the drop root. The drop’s entry page — whichever of index.html, index.md and README.md the drop root resolves to right now, that exact spelling, the page /d/ serves — cannot be deleted (400): replace it with PUT instead. That refusal is decided after the drop lookup and the existence check, so a foreign or missing drop still reads 404, as does a zip drop with no entry at its root; a nested sub/index.html is an ordinary file. Any other spelling is looked up like any other file, and a file is only removed under the exact name it is stored as: on a case-sensitive filesystem Index.html is a distinct file that PUT can create and DELETE removes; on a case-insensitive one it reads 404 rather than removing the real page. Like a write, a delete recomputes files and bytes, stamps updatedAt and re-renders the thumbnail; kind never flips back to html or md. Deleting everything but the entry page leaves a servable page.

Path parameters
NameTypeDescription
slugstring

The drop slug

pathstring

File path inside the drop, e.g. img/photo.png — same path rules as the write

Query parameters
NameTypeDescription
ownerstring

Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400.

Responses

200

Deleted — the removed path and the drop’s updated info

400

Unsafe or reserved path, the drop’s entry page, or an invalid owner handle

401

Missing or invalid API key

403

owner param without the admin role

404

No drop with that slug in the target namespace, no such file (a directory path included), or no path given

Example
curl -X DELETE https://dropp.sh/api/drops/demo/files/img/photo.png \
  -H "Authorization: Bearer $DROPPER_TOKEN"
response
{
  "deleted": "img/photo.png",
  "drop": {
    "slug": "demo",
    "owner": "alice",
    "name": "page.html",
    "description": "Launch page draft",
    "createdAt": "2026-08-27T12:00:00.000Z",
    "updatedAt": "2026-09-02T09:31:00.000Z",
    "kind": "zip",
    "files": 1,
    "bytes": 1024,
    "protected": false,
    "public": false,
    "archived": false,
    "url": "https://dropp.sh/d/alice/demo/"
  }
}
GET
/api/drops/{slug}/stats
API key

View statistics for one of your drops

Views, unique visitors, a dense per-bucket series and referrer / browser / device breakdowns for one of your drops over a preset range. Views are recorded server-side when the drop’s HTML is served at /d/ — unlocked views only, bots filtered by user agent, and nothing is ever injected into your page. A Markdown file rendered as a page for a browser is HTML and counts; the same file’s raw bytes never do, however they were asked for. Uniqueness comes from a hash of client address + user agent that rotates at UTC midnight (no cookies, no stored addresses), so any range that crosses UTC midnight — the 24h preset nearly always does, and every multi-day range — approximates unique visitors: a visitor returning on both sides of midnight counts twice. Statistics key on a stable per-drop identity, so they follow the drop through slug and handle renames. Archived drops keep answering, so their history stays viewable; a trashed drop is 404 until restored. Statistics ride along through the trash and come back on restore; they are erased for good when the drop is permanently deleted, purged from the trash, or reclaimed by the 30-day sweep. A drop created before statistics existed and not edited since answers tracked: false with zero counts — it starts recording from its next edit or re-create. Every count is a JSON number.

Path parameters
NameTypeDescription
slugstring

The drop slug

Query parameters
NameTypeDescription
range"24h" | "7d" | "30d" | "90d"

Preset range ending now: 24 hourly buckets, or 7 / 30 / 90 UTC-day buckets. Default 7d; anything else is 400.

ownerstring

Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400.

Responses

200

The statistics (cache-control: private, no-store)

400

Invalid range, or invalid owner handle

401

Missing or invalid API key

403

owner param without the admin role

404

No drop with that slug in the target namespace (trashed drops included)

503

The statistics database is unavailable — a fixed body, retry later

Example
curl "https://dropp.sh/api/drops/demo/stats?range=7d" \
  -H "Authorization: Bearer $DROPPER_TOKEN"
response
{
  "range": { "preset": "7d", "start": "2026-08-26T00:00:00.000Z", "end": "2026-09-01T14:02:11.318Z", "bucket": "day" },
  "tracked": true,
  "totals": { "views": 42, "visitors": 17 },
  "series": [
    { "start": "2026-08-26T00:00:00.000Z", "views": 3, "visitors": 2 },
    { "start": "2026-08-27T00:00:00.000Z", "views": 0, "visitors": 0 },
    { "start": "2026-09-01T00:00:00.000Z", "views": 11, "visitors": 6 }
  ],
  "breakdowns": {
    "referrers": [{ "name": "news.ycombinator.com", "count": 9 }],
    "browsers": [{ "name": "Chrome", "count": 30 }, { "name": "Safari", "count": 12 }],
    "devices": [{ "name": "desktop", "count": 28 }, { "name": "mobile", "count": 14 }]
  }
}
GET
/api/stats/summary
API key

View totals across all your drops

The numbers behind the dashboard’s stats tiles: views across every drop you own in a preset range, and the most viewed of them. Views are counted exactly as on GET /api/drops/{slug}/stats — HTML page views recorded at /d/, bots and locked views left out — and summed over your non-trashed drops: archived drops count, since their history stays; a trashed drop is left out until it is restored; a drop without a statistics id adds 0. top is the drop with the most views in the range, a tie going to the slug that sorts first, and null when no drop has a view. Your own drops only: there is no owner= reach-in (one given is ignored). A database failure is a 503 with a fixed body, as on the per-drop route.

Query parameters
NameTypeDescription
range"24h" | "7d" | "30d" | "90d"

Preset range ending now, the same presets as a drop’s statistics: 24 hourly buckets, or 7 / 30 / 90 UTC-day buckets. Default 7d; anything else is 400.

Responses

200

The totals (cache-control: private, no-store)

400

Invalid range

401

Missing or invalid API key

503

The statistics database is unavailable — a fixed body, retry later

Example
curl "https://dropp.sh/api/stats/summary?range=30d" \
  -H "Authorization: Bearer $DROPPER_TOKEN"
response
{
  "range": "30d",
  "views": 412,
  "top": { "slug": "demo", "name": "page.html", "views": 180 }
}
GET
/api/labels
API key

List your labels, as a tree

Your labels in tree order — depth-first, siblings sorted by name — each with its id, name, full path, parent id, colour and drop count. The colour is resolved: only top-level labels store one, and a sublabel shows its top-level label’s. The count rolls up: the distinct drops carrying the label or any of its sublabels, so a drop labelled both acme/site and acme/docs counts once on acme; trashed drops are left out, archived ones counted. Labels are per user and drops store label ids, so renaming or moving a label never rewrites a drop. You rarely need to create one first: a drop write naming a path that does not exist yet creates it (the labels field on POST and PATCH /api/drops). Your own labels only: there is no owner= reach-in (one given is ignored). An account with no labels gets an empty list.

Responses

200

Your labels in tree order (cache-control: private, no-store)

401

Missing or invalid API key

Example
curl https://dropp.sh/api/labels \
  -H "Authorization: Bearer $DROPPER_TOKEN"
response
{
  "labels": [
    { "id": "k3v9x0m2qa", "name": "acme", "path": "acme", "parent": null, "color": "purple", "drops": 3 },
    { "id": "p8d1z7w4rt", "name": "site", "path": "acme/site", "parent": "k3v9x0m2qa", "color": "purple", "drops": 2 },
    { "id": "h2n6c5y1ub", "name": "personal", "path": "personal", "parent": null, "color": "blue", "drops": 1 }
  ]
}
POST
/api/labels
API key

Create a label

Creates a label in your own tree, in one of two forms: path creates the label at that path along with any missing ancestor (acme/site creates acme too when it does not exist yet); name creates one label under the label whose id is parent, or at the top level when parent is omitted or null. Sending path together with name or parent is a 400. Names follow the handle rule — lowercase letters, digits and hyphens, 1–32 chars, starting with a letter or digit — and are unique among their siblings only, so acme/docs and personal/docs can both exist; a path is at most 3 names deep. Labels are per user: a parent id from another account’s tree is a 404. Unknown keys are ignored. The reply is the new label as GET /api/labels lists it.

JSON body
NameTypeRequiredDescription
pathstringoptional

The full path to create, e.g. acme/site — at most 3 names joined with /; missing ancestors are created with it. Send either path, or name (with parent), never both.

namestringoptional

The new label’s name: lowercase letters, digits and hyphens, 1–32 chars, starting with a letter or digit.

parentstringoptional

With name: the id of the label to create it under. Omitted or null creates a top-level label. Not allowed with path.

color"blue" | "green" | "purple" | "orange" | "cyan" | "red" | "gold" | "magenta" | "lime" | "grey"optional

Only when this call creates the top-level label — the one named, or the missing top level of path; anywhere else it is a 400. A new top-level label without one gets blue. Sublabels show their top-level label’s colour.

Responses

201

Label created — its row, with its resolved colour and drop count

400

No JSON object body, a field of the wrong JSON type, path sent with name or parent, an invalid name or path, a path deeper than 3 levels, an unknown colour, or a colour where the top level already exists

401

Missing or invalid API key

404

No label with that parent id in your labels

409

A label with that path already exists

Example
# by path: creates acme too when it does not exist yet; color is allowed only
# then, for that new top level (with acme already there it is a 400)
curl -X POST https://dropp.sh/api/labels \
  -H "Authorization: Bearer $DROPPER_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"path": "acme/site", "color": "purple"}'
# or by name under a parent id:
curl -X POST https://dropp.sh/api/labels \
  -H "Authorization: Bearer $DROPPER_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name": "docs", "parent": "k3v9x0m2qa"}'
response
{
  "id": "p8d1z7w4rt",
  "name": "site",
  "path": "acme/site",
  "parent": "k3v9x0m2qa",
  "color": "purple",
  "drops": 0
}
PATCH
/api/labels/{id}
API key

Rename, move or recolour a label

Renames, moves and recolours one of your labels, in any combination, checked against the end state; at least one field is required and unknown keys are ignored. Drops store label ids, so no drop is rewritten: the label and every sublabel under it show their new paths on the next read. A label moved to the top level keeps the colour it was showing unless color says otherwise; one moved under a parent takes on that parent’s colour. The reply is the label as GET /api/labels now lists it: new path, resolved colour, drop count.

Path parameters
NameTypeDescription
idstring

Label id from GET /api/labels. Always resolved in your own labels, so another account’s id is a 404

JSON body
NameTypeRequiredDescription
namestringoptional

New name — the same rule as on create; a name a sibling at the destination already has is a 409.

parentstringoptional

Move under the label with this id; null moves it to the top level; omitted leaves it where it is. A move under itself or one of its sublabels is a 400, and so is one that would nest any label — its sublabels count — deeper than 3 levels.

color"blue" | "green" | "purple" | "orange" | "cyan" | "red" | "gold" | "magenta" | "lime" | "grey"optional

New colour — top-level labels only: on a label that is, or by this move becomes, a sublabel it is a 400. Sublabels show their top-level label’s colour.

Responses

200

The label’s updated row

400

No field given, no JSON object body, a field of the wrong JSON type, an invalid name or unknown colour, a colour on a sublabel, a move under itself or one of its sublabels, or a move that would nest labels deeper than 3 levels

401

Missing or invalid API key

404

No label with that id — or no label with the given parent id — in your labels

409

A label at the destination already has that name

Example
# rename it:
curl -X PATCH https://dropp.sh/api/labels/p8d1z7w4rt \
  -H "Authorization: Bearer $DROPPER_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name": "website"}'
# move it to the top level and give it a colour of its own:
curl -X PATCH https://dropp.sh/api/labels/p8d1z7w4rt \
  -H "Authorization: Bearer $DROPPER_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"parent": null, "color": "green"}'
response
{
  "id": "p8d1z7w4rt",
  "name": "website",
  "path": "acme/website",
  "parent": "k3v9x0m2qa",
  "color": "purple",
  "drops": 2
}
DELETE
/api/labels/{id}
API key

Delete a label

Deletes one of your labels. Its sublabels and its drops move up to its parent: once acme/site is deleted, a drop labelled acme/site reads as acme, and acme/site/v2 becomes acme/v2. Deleting a top-level label turns its sublabels into top-level labels in its colour and takes it off its drops. A moved sublabel whose name is already taken where it lands merges into that label, its sublabels and drops with it, and cannot be split apart again. No drop is rewritten here: each drop resolves the change when it is read and stores the result on its next edit. The reply says where everything went.

Path parameters
NameTypeDescription
idstring

Label id from GET /api/labels. Always resolved in your own labels, so another account’s id is a 404

Responses

200

Deleted — the label’s id and where its sublabels and drops moved

401

Missing or invalid API key

404

No label with that id in your labels

Example
curl -X DELETE https://dropp.sh/api/labels/p8d1z7w4rt \
  -H "Authorization: Bearer $DROPPER_TOKEN"
response
{
  "id": "p8d1z7w4rt",
  "movedTo": { "id": "k3v9x0m2qa", "path": "acme" }
}
GET
/api/trash
API key

List your deleted drops

The drops you deleted that are still inside the 30-day window, newest deletion first, each with the trash id to act on and the expiresAt after which it may be reclaimed. Your own trash only: this route reads no owner= or all=1 — presenting one is not an error — so there is no admin reach-in and another account’s trash id simply does not exist for you. Listing also sweeps — entries past their expiry are purged before the reply is built. The sweep is opportunistic (it runs here and on every delete; nothing is scheduled), so an account nobody touches can hold expired entries past the window.

Responses

200

Your trashed drops

401

Missing or invalid API key

Example
curl https://dropp.sh/api/trash \
  -H "Authorization: Bearer $DROPPER_TOKEN"
response
{
  "trash": [
    {
      "id": "demo.1756598400000",
      "slug": "demo",
      "name": "page.html",
      "deletedAt": "2026-08-31T12:00:00.000Z",
      "expiresAt": "2026-09-30T12:00:00.000Z",
      "kind": "html",
      "files": 1,
      "bytes": 1024,
      "protected": false,
      "public": false,
      "archived": false
    }
  ]
}
POST
/api/trash/{id}
API key

Restore a deleted drop

Brings the drop back exactly as it was — content, description, labels, access code, gallery and archived flags all survive the round trip (a label deleted in the meantime reads as its parent, as on a live drop). It returns to the slug it was deleted from unless you name another one in the body. If that slug has been taken since (you reused it, which the delete freed on purpose), the restore is a 409 and the entry stays in the trash, so you can retry with a different slug. Restoring to the original slug also re-honors unlock cookies issued before the delete: the access code never changed, so neither did the cookie. The optional slug travels as JSON or as a form field (multipart/form-data or application/x-www-form-urlencoded).

Path parameters
NameTypeDescription
idstring

Trash id from GET /api/trash. Always resolved in your own namespace, so another account’s id is a 404

JSON body (optional)
NameTypeRequiredDescription
slugstringoptional

Restore under this slug instead of the original. Omitted, blank, or no body at all = the original slug

Responses

200

Restored — the drop info plus its public url

400

Unreadable body or an invalid target slug

401

Missing or invalid API key

404

No trash entry with that id in your trash

409

Target slug already taken — the entry stays in the trash; retry with another slug

Example
curl -X POST https://dropp.sh/api/trash/demo.1756598400000 \
  -H "Authorization: Bearer $DROPPER_TOKEN"
# the old slug is taken? restore it somewhere else:
curl -X POST https://dropp.sh/api/trash/demo.1756598400000 \
  -H "Authorization: Bearer $DROPPER_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"slug": "demo-restored"}'
DELETE
/api/trash/{id}
API key

Delete a trashed drop forever

Removes one trash entry, its content and its view statistics now, without waiting out the retention window. There is no undo for this one.

Path parameters
NameTypeDescription
idstring

Trash id from GET /api/trash. Always resolved in your own namespace, so another account’s id is a 404

Responses

204

Purged

401

Missing or invalid API key

404

No trash entry with that id in your trash

Example
curl -X DELETE https://dropp.sh/api/trash/demo.1756598400000 \
  -H "Authorization: Bearer $DROPPER_TOKEN"
GET
/api/export
API key

Download everything you own as a zip

One archive of all your content in its three states, under top-level folders: live/<slug>/…, archived/<slug>/… and deleted/<trashId>/… for trash entries still inside the retention window (the sweep runs first, so nothing already expired comes back). Content files only — each drop’s bookkeeping meta.json is left out, since it carries the access-code material, so the export holds no descriptions or labels; gallery thumbnails are not part of any drop. Your own content only: no owner=, no all=1. The zip is assembled in memory, so a very large account makes for a slow, memory-hungry request. An empty account still yields a valid, empty zip.

Responses

200

The zip, as an attachment named dropper-export-{handle}.zip (cache-control: private, no-store). An account with no content yields a valid, empty zip

401

Missing or invalid API key

Example
curl https://dropp.sh/api/export \
  -H "Authorization: Bearer $DROPPER_TOKEN" -o dropper-export.zip
unzip -l dropper-export.zip
#   live/demo2/index.html
#   archived/old-launch/index.html
#   deleted/demo.1756598400000/index.html
GET
/api/thumbnails/{handle}/{slug}
API key

Fetch the gallery thumbnail for a drop

A server-rendered PNG preview (~800×533, rendered headlessly at a 1200×800 viewport) of a passwordless drop, generated in the background after create, content replacement, password clear, and unarchive. Open to any authenticated user — it backs the gallery, so it is not limited to your own drops. Password-protected and archived drops never have a thumbnail: this route answers 404 for them even if a stale file lingers on disk, and so it does for every drop of a user whose pages an admin hid. A 404 also covers drops whose thumbnail has not been generated (yet, or at all — render failures are silent) — fall back to a placeholder.

Path parameters
NameTypeDescription
handlestring

Handle of the user who owns the drop

slugstring

The drop slug

Responses

200

The thumbnail (cache-control: private, max-age=60)

401

Missing or invalid API key

404

Unknown drop, password-protected or archived drop, a drop of a user whose pages an admin hid, or no thumbnail generated

Example
curl https://dropp.sh/api/thumbnails/alice/demo \
  -H "Authorization: Bearer $DROPPER_TOKEN" -o thumb.png
GET
/api/me
API key

Who you are: the account and the credential behind this request

Answers any credential the drop routes accept — a personal API key, an OAuth access token (an MCP connector’s or dropper login’s), or a signed-in session — with the account it belongs to (handle, name, email, role) and which credential answered. role is the live one, re-read from DROPPER_ADMIN_EMAILS on every request. credential.kind is api_key (with the key’s name and masked start as the API Keys page shows them, its creation and its expiry — null when it never expires), oauth (with the token’s granted scopes) or session. Never the user id, the key itself, its id or hash, or any token. Always your own identity: there is no owner= reach-in, and one given is ignored. A bearer that is neither a live key nor an OAuth access token is a hard 401, never a fall-through to the session cookie. Always cache-control: private, no-store. This is what dropper whoami prints.

Responses

200

The identity (cache-control: private, no-store)

401

Missing or invalid credential — no key, OAuth access token or session, or a bearer that is neither a key nor a token

403

The account is suspended ({"error":"account suspended"} — the credential itself is valid, the account behind it is not), or a connector token was never granted the drops scope

Example
curl https://dropp.sh/api/me \
  -H "Authorization: Bearer $DROPPER_TOKEN"
response
{
  "handle": "alice",
  "name": "Alice Example",
  "email": "alice@example.com",
  "role": "user",
  "credential": {
    "kind": "api_key",
    "name": "laptop",
    "start": "hYqTbc",
    "createdAt": "2026-09-10T08:12:45.120Z",
    "expiresAt": null
  }
}
PATCH
/api/profile
Session only

Rename your handle (moves your whole /d/{user}/ namespace)

Changes your handle and moves your whole /d/<handle>/ namespace with it (the dashboard Profile page is the intended way in). Consequences are immediate: every URL under the old handle hard-404s (no redirects), viewers of protected drops re-enter the access code, and the freed handle becomes claimable by anyone. The handle is lowercased before validation; renaming to your current handle is a no-op 200. Session-only: a signed-in dashboard session is required, and — unlike the drop routes — presenting any bearer, an API key or an MCP access token, even a valid one, is a hard 401, so a leaked credential cannot release your namespace for takeover. Existing sessions and API keys keep working afterwards and reflect the new handle on their next request.

JSON body
NameTypeRequiredDescription
handlestring
required

The new handle (lowercase letters, digits, hyphens; max 32 chars)

Responses

200

Handle renamed (or already yours) — returns the normalized new handle

400

Missing or invalid handle

401

Not signed in — or any bearer was presented (this endpoint is session-only)

422

Handle already taken

Example
# Session-only: use the Profile page, or a signed-in session cookie.
# Bearers are refused here — Authorization: Bearer always 401s.
# Over HTTPS the cookie name carries the __Secure- prefix (plain
# better-auth.session_token on a local http:// dev server).
curl -X PATCH https://dropp.sh/api/profile \
  -b "__Secure-better-auth.session_token=$SESSION_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"handle": "new-handle"}'
response
{
  "handle": "new-handle"
}
GET
/api/connections
Session only

List the OAuth apps connected to your account

The apps you have authorized through the MCP connector flow (claude.ai and anything else that runs OAuth against Dropper), newest grant first, with the client display name and the scopes you granted. Session-only: presenting any bearer — an API key or an MCP access token, even a valid one — is a hard 401, so a connector cannot enumerate your other grants.

Responses

200

Your connected apps

401

Not signed in — or any bearer was presented (this endpoint is session-only)

Example
# Session-only: the Connected apps card on the API Keys page, or a signed-in
# session cookie. Bearers are refused here — Authorization: Bearer always 401s.
curl https://dropp.sh/api/connections \
  -b "__Secure-better-auth.session_token=$SESSION_TOKEN"
response
{
  "connections": [
    {
      "id": "c9Xq2LmT7vRk4NwB8pZs1YhD6gFj3aKe",
      "clientId": "https://claude.ai/oauth/mcp-oauth-client-metadata",
      "name": "Claude",
      "clientUri": "https://claude.ai",
      "scopes": ["drops", "offline_access"],
      "grantedAt": "2026-09-03T16:40:12.000Z"
    }
  ]
}
DELETE
/api/connections
Session only

Disconnect an OAuth app

Revokes the consent named by id and purges that app's stored tokens for your account, so its refresh token stops working immediately. Access tokens already issued are self-contained and stay valid until they expire (within the hour). The next time the app asks for access you will be shown the consent screen again. An unknown id — or another user's — is a 404 and deletes nothing. Session-only, like the listing.

Query parameters
NameTypeDescription
idstring

The connection (consent) id from GET /api/connections

Responses

200

App disconnected

400

Missing id query parameter

401

Not signed in — or any bearer was presented (this endpoint is session-only)

404

No such connection for this account

Example
# Session-only, like the listing. The id is a connection's id from GET /api/connections.
curl -X DELETE "https://dropp.sh/api/connections?id=c9Xq2LmT7vRk4NwB8pZs1YhD6gFj3aKe" \
  -b "__Secure-better-auth.session_token=$SESSION_TOKEN"
response
{
  "id": "c9Xq2LmT7vRk4NwB8pZs1YhD6gFj3aKe",
  "clientId": "https://claude.ai/oauth/mcp-oauth-client-metadata"
}
GET
/api/admin/users
Session only

List every user with their usage (admin only)

The roster behind the Admin › Users page: every account in the auth database with its role (computed live from DROPPER_ADMIN_EMAILS, never a cached column), joined date, last sign-in (cookie sign-ins only — API-key and connector use never counts; accounts older than the column fall back to their newest session, null means never), drops (count; public, protected and archived counts; content bytes — the sum of each live drop’s bytes, archived included, meta.json and thumbnails excluded; the trash is counted separately), API keys (count, enabled, last used), connected apps, active sessions and views over the last 30 UTC days (30 UTC-day buckets ending now — the first starts at UTC midnight 29 days back, the last is today so far — so approximate as a rolling 30 days), plus the handle directories under the drops directory that no account claims (orphans — left behind by a deleted account or an interrupted rename; reported, never acted on). The views query degrades on its own: if the statistics table misbehaves the listing still answers with viewsAvailable false and every views30d null, the rest of the listing intact. Metadata only: no drop ids, no key material, no session tokens, no better-auth user ids — handles are the identifiers. suspendedAt is set while an account is suspended; pagesHidden reflects the <handle>/.pages-hidden marker on disk. Admin-only and session-only: a signed-in admin session is required — any bearer, an API key or an MCP access token, is a hard 401, and a non-admin session is a 403 before anything is looked at.

Responses

200

The roster, users sorted by handle (cache-control: private, no-store)

401

Not signed in — or any bearer was presented (this endpoint is session-only)

403

Signed in, but not an admin

Example
# Admin-only and session-only: the Admin › Users page, or a signed-in admin's
# session cookie. Bearers are refused here — Authorization: Bearer always 401s.
curl https://dropp.sh/api/admin/users \
  -b "__Secure-better-auth.session_token=$SESSION_TOKEN"
response
{
  "users": [
    {
      "handle": "alice",
      "name": "Alice",
      "email": "alice@example.com",
      "role": "user",
      "createdAt": "2026-08-27T12:00:00.000Z",
      "lastLoginAt": "2026-09-08T18:21:07.000Z",
      "suspendedAt": null,
      "pagesHidden": false,
      "hasDirectory": true,
      "drops": { "count": 4, "bytes": 1834021, "publicCount": 1, "protectedCount": 1, "archivedCount": 1, "lastChangeAt": "2026-09-02T09:31:00.000Z" },
      "trash": { "count": 1, "bytes": 1024 },
      "apiKeys": { "count": 1, "enabledCount": 1, "lastUsedAt": "2026-09-08T18:30:12.000Z" },
      "connectedApps": 1,
      "sessions": { "active": 2, "latestCreatedAt": "2026-09-08T18:21:07.000Z" },
      "views30d": 42
    }
  ],
  "orphanDirectories": [{ "handle": "old-account", "drops": 2, "bytes": 40960 }],
  "viewsAvailable": true,
  "generatedAt": "2026-09-09T10:00:00.000Z"
}
GET
/api/admin/users/{handle}
Session only

Inspect one user (admin only)

The same summary as the listing plus the account’s drops as metadata (slug as text, kind, files, bytes, created, updated, the public / protected / archived flags and 30-day views — no url, no thumbnail, no file list), its API keys (name and the masked start the Keys page shows, never the key), its connected apps (client name, scopes, granted date) and its active sessions (created, last active, expires, IP, user agent — no token). Trash is counts only: the admin never reaches into another account’s trash. The handle is matched exactly, never normalised; an orphan directory is not a user (404). Admin-only and session-only, like the listing.

Path parameters
NameTypeDescription
handlestring

The user’s handle, exactly as the roster shows it — never normalised (an uppercase letter is a 400, not a lookup)

Responses

200

The user detail — drops, API keys and connected apps newest first, sessions active only (cache-control: private, no-store)

400

Invalid handle

401

Not signed in — or any bearer was presented (this endpoint is session-only)

403

Signed in, but not an admin

404

No user with that handle

Example
curl https://dropp.sh/api/admin/users/alice \
  -b "__Secure-better-auth.session_token=$SESSION_TOKEN"
response
{
  "user": {
    "handle": "alice",
    "name": "Alice",
    "email": "alice@example.com",
    "role": "user",
    "createdAt": "2026-08-27T12:00:00.000Z",
    "lastLoginAt": "2026-09-08T18:21:07.000Z",
    "suspendedAt": null,
    "pagesHidden": false,
    "hasDirectory": true,
    "drops": { "count": 1, "bytes": 1024, "publicCount": 0, "protectedCount": 0, "archivedCount": 0, "lastChangeAt": "2026-09-02T09:31:00.000Z" },
    "trash": { "count": 1, "bytes": 1024 },
    "apiKeys": { "count": 1, "enabledCount": 1, "lastUsedAt": "2026-09-08T18:30:12.000Z" },
    "connectedApps": 1,
    "sessions": { "active": 1, "latestCreatedAt": "2026-09-08T18:21:07.000Z" },
    "views30d": 42
  },
  "drops": [
    {
      "slug": "demo",
      "name": "page.html",
      "kind": "html",
      "files": 1,
      "bytes": 1024,
      "createdAt": "2026-08-27T12:00:00.000Z",
      "updatedAt": "2026-09-02T09:31:00.000Z",
      "protected": false,
      "public": false,
      "archived": false,
      "views30d": 42
    }
  ],
  "trash": { "count": 1, "bytes": 1024 },
  "apiKeys": [
    {
      "name": "laptop",
      "start": "hYqTbc",
      "prefix": null,
      "enabled": true,
      "createdAt": "2026-08-27T12:05:00.000Z",
      "lastRequest": "2026-09-08T18:30:12.000Z",
      "expiresAt": null,
      "requestCount": 0
    }
  ],
  "connectedApps": [
    {
      "clientId": "https://claude.ai/oauth/mcp-oauth-client-metadata",
      "name": "Claude",
      "scopes": ["drops", "offline_access"],
      "grantedAt": "2026-09-03T16:40:12.000Z"
    }
  ],
  "sessions": [
    {
      "createdAt": "2026-09-08T18:21:07.000Z",
      "updatedAt": "2026-09-08T18:21:07.000Z",
      "expiresAt": "2026-09-15T18:21:07.000Z",
      "ipAddress": "203.0.113.24",
      "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15"
    }
  ],
  "viewsAvailable": true
}
PATCH
/api/admin/users/{handle}
Session only

Suspend or unsuspend a user (admin only)

The one account action the dashboard has. Suspending stamps suspendedAt — from that moment the account cannot sign in and every credential of theirs, API keys, OAuth tokens (connectors and dropper login) and any surviving session, answers 403 account suspended on every route — then deletes their sessions and their OAuth access and refresh tokens (consents stay, so a re-authorise after unsuspend skips the consent screen; API keys stay in place and are simply refused) and, last, applies hidePages — true writes the <handle>/.pages-hidden marker so every /d/ URL, thumbnail and gallery entry of theirs answers 404 exactly like an unknown slug; false removes it while the account stays suspended; omitted leaves it as it is, so a repeat suspend never silently flips pages. Repeating suspended: true re-runs the purge and keeps the original timestamp. Unsuspending removes the marker first, then clears the stamp: cookie sign-in and API keys work again with nothing to re-mint, and connectors and dropper login must re-authorise. hidePages must be omitted or false with suspended: false. Unsuspending an active account is a no-op 200 that still clears a leftover marker (the pages-hidden-but-active case a database-only restore can leave). Admins by the live DROPPER_ADMIN_EMAILS — the caller included — cannot be suspended (400): remove the email from the env first. Accepted residual: an MCP access token already minted passes the hosted transport’s stateless check for the rest of its hour but is refused at the web API.

Path parameters
NameTypeDescription
handlestring

The user’s handle, exactly as the roster shows it — never normalised (an uppercase letter is a 400, not a lookup)

JSON body
NameTypeRequiredDescription
suspendedboolean
required

true suspends, false unsuspends. A JSON boolean — the string "true" is a 400

hidePagesbooleanoptional

With suspended: true — true takes their pages offline (writes the pages-hidden marker), false brings them back (removes it), omitted leaves them as they are. With suspended: false it must be omitted or false (unsuspending always brings the pages back)

Responses

200

The user’s updated summary (cache-control: private, no-store) — suspendedAt, pagesHidden and sessions reflect the result

400

Invalid handle; a body without a JSON boolean suspended; hidePages not a boolean, or true with suspended: false; or the target is an admin (admins cannot be suspended)

401

Not signed in — or any bearer was presented (this endpoint is session-only)

403

Signed in, but not an admin

404

No user with that handle

Example
# suspend, pages staying online:
curl -X PATCH https://dropp.sh/api/admin/users/alice \
  -b "__Secure-better-auth.session_token=$SESSION_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"suspended": true}'
# suspend and take their pages offline too:
curl -X PATCH https://dropp.sh/api/admin/users/alice \
  -b "__Secure-better-auth.session_token=$SESSION_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"suspended": true, "hidePages": true}'
# let them back in (pages come back with them):
curl -X PATCH https://dropp.sh/api/admin/users/alice \
  -b "__Secure-better-auth.session_token=$SESSION_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"suspended": false}'
response
{
  "user": {
    "handle": "alice",
    "name": "Alice",
    "email": "alice@example.com",
    "role": "user",
    "createdAt": "2026-08-27T12:00:00.000Z",
    "lastLoginAt": "2026-09-08T18:21:07.000Z",
    "suspendedAt": "2026-09-09T10:05:00.000Z",
    "pagesHidden": true,
    "hasDirectory": true,
    "drops": { "count": 4, "bytes": 1834021, "publicCount": 1, "protectedCount": 1, "archivedCount": 1, "lastChangeAt": "2026-09-02T09:31:00.000Z" },
    "trash": { "count": 1, "bytes": 1024 },
    "apiKeys": { "count": 1, "enabledCount": 1, "lastUsedAt": "2026-09-08T18:30:12.000Z" },
    "connectedApps": 1,
    "sessions": { "active": 0, "latestCreatedAt": null },
    "views30d": 42
  }
}
GET
/api/version

The deployment’s version and release stamp (public)

The version, build time and commit baked into the running image — what the dashboard sidebar shows at the bottom. Public: no credential of any kind is required or consulted. A tagged release reads MAJOR.MINOR.PATCH; a branch build (staging) reads 0.0.0-<branch>.<short sha>; a plain local build reads dev with releasedAt and commit null. Always cache-control: no-store.

Responses

200

The version info — releasedAt and commit are null when the image carries no stamp (cache-control: no-store)

Example
curl https://dropp.sh/api/version
response
{
  "version": "0.17.0",
  "releasedAt": "2026-09-17T16:16:03Z",
  "commit": "ace5d48c0f1e2b3a4d5c6e7f8a9b0c1d2e3f4a5b"
}
GET
/d/{user}/{slug}/{path}

Serve drop content (public)

Serves the dropped page and its assets from the owner’s namespace. The drop root — and any directory inside it — serves the first of index.html, index.md and README.md that exists there, exact spellings and index.html first, so every drop made before index.md existed resolves as it always did. So that relative assets resolve, the slash-less drop root always redirects to its trailing slash (308), and so does a directory inside the drop that holds one of those entry pages; a directory without one is a 404 with or without the slash. Protected drops answer 401 — page-like requests get a styled unlock page, assets get a bare 401. Archived and deleted drops — and every drop of a user whose pages an admin hid — answer 404, indistinguishably from a slug that was never used. Unprotected content answers cache-control: private, max-age=60 — a browser may reuse it for a minute, but no shared or CDN cache ever stores it; protected content is private, no-store. The drop-root meta.json is reserved and never served. A .md file has two faces behind one URL: it renders as a styled HTML page — table of contents, syntax-highlighted code, Mermaid diagrams — when Accept lists text/html and does not list text/markdown, and serves the stored bytes otherwise, so curl (*/*), a request with no Accept, and an agent that names text/markdown all keep exactly the stored bytes and their text/plain type. ?raw=1 forces the stored bytes whatever Accept says, and is the form to prefer in a script or a cache key: it is a different URL rather than a second representation of one. Both .md variants carry Vary: Accept; nothing else this route serves does. The raw contract never depends on the renderer — a .md over 1 MB, one whose rendered page would exceed 4 MB, and a render that throws all fall back to the stored bytes with the normal headers and a 200, never a 500 or a truncated body.

Path parameters
NameTypeDescription
userstring

Handle of the user who owns the drop

slugstring

The drop slug

pathstring

File path inside the drop; empty means the drop’s entry page

Query parameters
NameTypeDescription
rawstring

The literal value 1 forces the stored bytes for a .md file, whatever Accept says; any other value (raw=true, raw=, a bare raw) is ignored and the Accept rule applies. Ignored entirely on every other path — no header changes, no Vary.

Responses

200

The file, with its content type — or, for a .md negotiated as a page, the rendered HTML (text/html; charset=utf-8, plus Vary: Accept on both .md variants)

308

Redirect adding a trailing slash — always for the drop root, and for a directory only when it holds an entry page

401

Protected and not unlocked — unlock page or bare 401

404

Unknown user, drop, or file — or an archived drop, or a drop of a user whose pages an admin hid

Example
curl "https://dropp.sh/d/alice/demo/notes.md?raw=1"
GET
/d/{user}/{slug}/

Serve the drop root (public)

Serves the drop’s entry page — the first of index.html, index.md and README.md present at its root. A Markdown entry renders as a styled page for browsers (titled with the drop’s display name minus a trailing .md, never from an h1 in the document) and serves its source to everything else, exactly as a nested .md does. Protected drops answer 401 with the unlock page until unlocked; archived drops answer 404.

Path parameters
NameTypeDescription
userstring

Handle of the user who owns the drop

slugstring

The drop slug

Responses

200

The drop’s entry page

401

Protected and not unlocked — the unlock page

404

Unknown or archived drop, or a drop of a user whose pages an admin hid

Example
curl https://dropp.sh/d/alice/demo/
POST
/d/{user}/{slug}/

Unlock a protected drop (viewer)

The unlock page posts the access code here. A correct code sets a path-scoped, HttpOnly HMAC cookie valid for 30 days — derived from the current password hash, so rotating or clearing the code revokes every issued cookie. Attempts are throttled per drop (20 per minute, shared across viewers).

Path parameters
NameTypeDescription
userstring

Handle of the user who owns the drop

slugstring

The drop slug

JSON body
NameTypeRequiredDescription
passwordstring
required

The access code (trimmed; max 256 chars)

Responses

204

Unlocked — Set-Cookie scoped to /d/{user}/{slug}, Max-Age 30 days

400

Drop is not protected

403

Wrong access code

404

Unknown or archived drop, or a drop of a user whose pages an admin hid

413

Body over 4 KB

429

Too many attempts — wait up to a minute

Example
curl -X POST https://dropp.sh/d/alice/demo/ \
  -H 'Content-Type: application/json' \
  -d '{"password": "correct-horse-battery-staple"}' -i
Limits

Upload size

50 MB

Extracted size

200 MB

Zip entries

2000 files

Single-file write

20 MB per file on PUT /api/drops/<slug>/files/<path>, raw body and ?from= fetch alike; the drop as a whole must still fit the extracted size and entry caps above

Markdown render

1 MB per .md file (never .markdown), and 4 MB for the page it renders into. Over either, the file keeps its URL and is served as the stored bytes, exactly as ?raw=1 would — the page is the thing that is skipped, never the content. A page also lists at most 2,000 headings in its table of contents; the rest still get their ids, so every deep link keeps working

Slug

lowercase letters, digits, hyphens; 1–64 chars; must start with a letter or digit

Handle

lowercase letters, digits, hyphens; 1–32 chars — from your Authelia username at first sign-in (a collision gets a random -xxxx suffix); renameable any time via the Profile page or PATCH /api/profile (session-only; API keys refused)

Password

trimmed; max 256 chars

Description

trimmed; max 200 chars; public — the gallery shows it

Labels

names: lowercase letters, digits, hyphens; 1–32 chars; unique among their siblings. At most 3 levels deep (project/area/topic), and at most 20 paths in one labels field, so 20 labels per drop. 10 colours, set on top-level labels only — sublabels show their top-level label's

Unlock attempts

20 per minute per drop

Trash retention

30 days from deletion. Purging is opportunistic — the sweep runs when you list your trash or delete another drop, so an untouched account can hold expired entries past the window

Zip extraction rejects path traversal and absolute entry names; the drop-root meta.json is reserved and never served.
Docsopenapi.jsoninstall.shskill.mdcli/manifest.json