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.
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:
- Sign in to the dashboard with Authelia.
- Open the API Keys page and create a key — it is shown exactly once, so copy it right there.
- 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_TOKENRevoke 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.
/api/drops
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
| Name | Type | Required | Description |
|---|---|---|---|
file | file | 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. |
slug | string | optional | Custom slug (lowercase letters, digits, hyphens; max 64 chars). Random 8 chars when omitted. |
name | string | optional | 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. |
description | string | optional | Short description (trimmed; max 200 chars) — public: the gallery shows it when the drop is public. Blank or omitted means none. |
labels | string | optional | Label paths, comma-separated: |
password | string | optional | 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{
"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/"
}/api/drops
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
| Name | Type | Description |
|---|---|---|
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. |
label | string | 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"{
"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/"
}
]
}/api/drops/{slug}
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
| Name | Type | Description |
|---|---|---|
slug | string | Slug of the drop to edit or delete, within the target namespace |
Query parameters
| Name | Type | Description |
|---|---|---|
owner | string | 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
| Name | Type | Required | Description |
|---|---|---|---|
slug | string | optional | New slug (rename); the public URL changes. |
name | string | optional | 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. |
description | string | optional | New description (trimmed; max 200 chars). Omitted leaves it unchanged; blank clears it — unlike name, where blank leaves the name as it is. |
labels | string | optional | Label paths, comma-separated: |
password | string | optional | 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. |
file | file | optional | 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=/api/drops/{slug}
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
| Name | Type | Description |
|---|---|---|
slug | string | Slug of the drop to edit or delete, within the target namespace |
Query parameters
| Name | Type | Description |
|---|---|---|
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. |
owner | string | 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"{
"status": "trashed",
"trashId": "demo.1756598400000",
"slug": "demo",
"expiresAt": "2026-09-30T12:00:00.000Z"
}/api/drops/{slug}/files
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
| Name | Type | Description |
|---|---|---|
slug | string | The drop slug |
Query parameters
| Name | Type | Description |
|---|---|---|
owner | string | 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"{
"files": [
{ "path": "assets/app.css", "bytes": 14, "contentType": "text/css; charset=utf-8" },
{ "path": "index.html", "bytes": 1024, "contentType": "text/html; charset=utf-8" }
]
}/api/drops/{slug}/files/{path}
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
| Name | Type | Description |
|---|---|---|
slug | string | The drop slug |
path | string | File path inside the drop, e.g. index.html or assets/app.css |
Query parameters
| Name | Type | Description |
|---|---|---|
owner | string | 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"/api/drops/{slug}/files/{path}
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
| Name | Type | Description |
|---|---|---|
slug | string | The drop slug |
path | string | 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
| Name | Type | Description |
|---|---|---|
from | URL | 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 ( |
owner | string | 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)
| Name | Type | Required | Description |
|---|---|---|---|
body | bytes | optional | The file’s content as-is (raw-body mode — no |
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"{
"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/"
}
}/api/drops/{slug}/files/{path}
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
| Name | Type | Description |
|---|---|---|
slug | string | The drop slug |
path | string | File path inside the drop, e.g. img/photo.png — same path rules as the write |
Query parameters
| Name | Type | Description |
|---|---|---|
owner | string | 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"{
"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/"
}
}/api/drops/{slug}/stats
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
| Name | Type | Description |
|---|---|---|
slug | string | The drop slug |
Query parameters
| Name | Type | Description |
|---|---|---|
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. |
owner | string | 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"{
"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 }]
}
}/api/stats/summary
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
| Name | Type | Description |
|---|---|---|
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"{
"range": "30d",
"views": 412,
"top": { "slug": "demo", "name": "page.html", "views": 180 }
}/api/labels
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"{
"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 }
]
}/api/labels
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
| Name | Type | Required | Description |
|---|---|---|---|
path | string | optional | The full path to create, e.g. |
name | string | optional | The new label’s name: lowercase letters, digits and hyphens, 1–32 chars, starting with a letter or digit. |
parent | string | optional | 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"}'{
"id": "p8d1z7w4rt",
"name": "site",
"path": "acme/site",
"parent": "k3v9x0m2qa",
"color": "purple",
"drops": 0
}/api/labels/{id}
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
| Name | Type | Description |
|---|---|---|
id | string | Label id from GET /api/labels. Always resolved in your own labels, so another account’s id is a 404 |
JSON body
| Name | Type | Required | Description |
|---|---|---|---|
name | string | optional | New name — the same rule as on create; a name a sibling at the destination already has is a 409. |
parent | string | optional | 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"}'{
"id": "p8d1z7w4rt",
"name": "website",
"path": "acme/website",
"parent": "k3v9x0m2qa",
"color": "purple",
"drops": 2
}/api/labels/{id}
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
| Name | Type | Description |
|---|---|---|
id | string | 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"{
"id": "p8d1z7w4rt",
"movedTo": { "id": "k3v9x0m2qa", "path": "acme" }
}/api/trash
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"{
"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
}
]
}/api/trash/{id}
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
| Name | Type | Description |
|---|---|---|
id | string | Trash id from GET /api/trash. Always resolved in your own namespace, so another account’s id is a 404 |
JSON body (optional)
| Name | Type | Required | Description |
|---|---|---|---|
slug | string | optional | 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"}'/api/trash/{id}
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
| Name | Type | Description |
|---|---|---|
id | string | 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"/api/export
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/api/thumbnails/{handle}/{slug}
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
| Name | Type | Description |
|---|---|---|
handle | string | Handle of the user who owns the drop |
slug | string | 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/api/me
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"{
"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
}
}/api/profile
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
| Name | Type | Required | Description |
|---|---|---|---|
handle | string | 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"}'{
"handle": "new-handle"
}/api/connections
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"{
"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"
}
]
}/api/connections
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
| Name | Type | Description |
|---|---|---|
id | string | 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"{
"id": "c9Xq2LmT7vRk4NwB8pZs1YhD6gFj3aKe",
"clientId": "https://claude.ai/oauth/mcp-oauth-client-metadata"
}/api/admin/users
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"{
"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"
}/api/admin/users/{handle}
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
| Name | Type | Description |
|---|---|---|
handle | string | 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"{
"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
}/api/admin/users/{handle}
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
| Name | Type | Description |
|---|---|---|
handle | string | The user’s handle, exactly as the roster shows it — never normalised (an uppercase letter is a 400, not a lookup) |
JSON body
| Name | Type | Required | Description |
|---|---|---|---|
suspended | boolean | required | true suspends, false unsuspends. A JSON boolean — the string "true" is a 400 |
hidePages | boolean | optional | 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}'{
"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
}
}/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{
"version": "0.17.0",
"releasedAt": "2026-09-17T16:16:03Z",
"commit": "ace5d48c0f1e2b3a4d5c6e7f8a9b0c1d2e3f4a5b"
}/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
| Name | Type | Description |
|---|---|---|
user | string | Handle of the user who owns the drop |
slug | string | The drop slug |
path | string | File path inside the drop; empty means the drop’s entry page |
Query parameters
| Name | Type | Description |
|---|---|---|
raw | string | 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"/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
| Name | Type | Description |
|---|---|---|
user | string | Handle of the user who owns the drop |
slug | string | 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//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
| Name | Type | Description |
|---|---|---|
user | string | Handle of the user who owns the drop |
slug | string | The drop slug |
JSON body
| Name | Type | Required | Description |
|---|---|---|---|
password | string | 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"}' -iUpload 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