Reference

REST API

Base URL: https://dropfast.dev/api/v1

Status & discovery

GET /api/status is the public, unauthenticated entry point — an agent can hit it first to learn what it's talking to and where everything else lives. It returns:

  • appVersion — the deployed product version.
  • apiVersion — the REST contract version (matches the OpenAPI doc's info.version).
  • skill — the dropfast-publish skill version the live API recommends, the canonical hosted skill url, and a one-line upgrade command. Installed skills compare their local version against this to self-update.
  • discovery — absolute links to the spec, llms.txt, docs, MCP server, tool schemas, and the API-keys page.
bash
curl https://dropfast.dev/api/status
jsonc
{
  "success": true,
  "data": {
    "status": "ok",
    "service": "dropfast",
    "appVersion": "1.6.0",
    "apiVersion": "1.1.0",
    "apiBase": "https://dropfast.dev/api/v1",
    "skill": {
      "name": "dropfast-publish",
      "version": "1.3.0",
      "url": "https://dropfast.dev/dropfast.SKILL.md",
      "upgrade": "curl -fsSL https://dropfast.dev/dropfast.SKILL.md -o ~/.claude/skills/dropfast/SKILL.md"
    },
    "discovery": { "openapi": "…", "llmsTxt": "…", "mcp": "…", "...": "…" },
    "generatedAt": "2026-06-23T00:00:00.000Z"
  }
}

It's short-cached (60s) with permissive CORS so agents can poll it cheaply.

Machine-readable spec

The API is described by an OpenAPI 3.1 document. Use it to generate client SDKs, drive Postman collections, or wire up agent tool-call schemas.

  • OpenAPI JSON: /api/openapi.json — the raw spec. Cached for 5 minutes.
  • Interactive explorer: /docs/openapi — Scalar UI with a built-in "Try It Out" panel. Paste a test key to send live requests.
  • Discovery: /.well-known/api-catalog (RFC 9727 linkset pointing at every machine-readable surface).

Authentication

Every request authenticates with your API key sent as a bearer token. This is the canonical header:

http
Authorization: Bearer df_sk_...

The legacy x-api-key: df_sk_... header is still accepted for back-compat (Bearer wins if both are sent), but new clients should use Authorization: Bearer.

Keys are scoped to your account. Create and revoke keys at /dashboard/api-keys. A revoked key is rejected immediately — there's no propagation delay.

The dashboard UI uses a Clerk session instead. You don't need an API key to publish from the browser.

Endpoints

POST /api/v1/sites — publish a new site

Multipart upload. Fields:

FieldRequiredTypeNotes
fileyesfile.html, .htm, or .zip. Max 50 MB.
namenostringDisplay name. Used to seed the slug.
accessModenoenumpublic (API default), private, password, or restricted (set restricted via PATCH, then add grants — see Access control). Agent SDKs and the dropfast-publish skill default to private — see /docs/agents.
passwordcond.stringRequired when accessMode is password.
commentsEnablednostring"true" (default) keeps the inline comments overlay on the new site, "false" opts out. Multipart only accepts strings; any value other than "true"/"false" is ignored and falls back to the default (true).
ogImageUrlnostringSocial-share card: an https:// URL or a single-leading-slash site path. Omit for the auto card (or bundle a root og_image.png in a zip — see Social sharing).
metadatanostringJSON-stringified metadata object (multipart can't nest), e.g. {"df":{"project":"auth-redesign","type":"review"}}. Setting df.project + df.type at publish makes the site recallable via search, ?metadata.* filters, and the dashboard organizer. Contract: Metadata.
bash
curl -X POST https://dropfast.dev/api/v1/sites \
  -H "Authorization: Bearer df_sk_..." \
  -F "file=@./index.html" \
  -F "name=launch-plan" \
  -F "accessMode=private" \
  -F "commentsEnabled=true"

Returns 201 with the new site's slug, url, and metadata.

GET /api/v1/sites — list, filter, and search your sites

bash
curl https://dropfast.dev/api/v1/sites \
  -H "Authorization: Bearer df_sk_..."

Returns 200 with data.sites as an array and data.nextCursor for pagination. Each item carries a title and excerpt — plain text extracted from the site's primary HTML/MD file at publish (first <h1>, frontmatter title, or <title>; ~200-char excerpt) — so a list of slugs reads as legible artifacts. Both are null for sites published before extraction shipped; fall back to name/slug.

Query parameters (all optional, freely combinable — filters AND together):

ParamNotes
qFree-text search, max 200 chars. Fuzzy + case-insensitive; matches partial words against the site name, slug, df.*/user metadata values, and extracted content. Empty/whitespace = no search.
metadata.<key>Structured filter, e.g. ?metadata.df.project=auth-redesign&metadata.df.type=review. Same dotted-key contract as the Metadata section.
sortcreated (default, newest first), updated, name, or relevance. relevance requires q, ranks by full-text match quality, and returns a bounded window of up to 100 results with no cursor.
cursorOpaque cursor from the previous response's nextCursor. A cursor is bound to the sort it was minted under — replaying it with a different sort returns 400 CURSOR_SORT_MISMATCH, never a silently wrong page.
limitPage size 1–100, default 20.
bash
# "the auth-redesign reviews, most recently updated first"
curl "https://dropfast.dev/api/v1/sites?q=auth+review&metadata.df.type=review&sort=updated" \
  -H "Authorization: Bearer df_sk_..."

The MCP equivalent is the query_sites tool — same filters, same search term, same result shape (see Agents).

GET /api/v1/sites/{slug} — fetch one site

bash
curl https://dropfast.dev/api/v1/sites/launch-plan-a1b2 \
  -H "Authorization: Bearer df_sk_..."

Returns the same fields as a list item — including the site's metadata object — plus isActive.

PUT /api/v1/sites/{slug} — replace site files

Multipart upload with a file field. Replaces every file under the slug. The slug, URL, and access settings do not change.

bash
curl -X PUT https://dropfast.dev/api/v1/sites/launch-plan-a1b2 \
  -H "Authorization: Bearer df_sk_..." \
  -F "file=@./plan-v2.html"

PATCH /api/v1/sites/{slug} — update settings only

JSON body. Updates name, accessMode, password, commentsEnabled, ogImageUrl, and/or metadata. Does not touch files. Omit any field to leave it unchanged.

ogImageUrl sets the social-share card: an https:// URL, a single-leading-slash site path, or null to clear back to the auto-generated card. Every site response also carries a resolved ogImage (the effective absolute card URL). See Social sharing for the upload endpoint and the zip-root og_image.* convention.

metadata here replaces the stored object wholesale ({} clears it) — read-merge client-side if you only want to add or remove keys. The bulk endpoint's meta op and the CLI's meta set do that merge for you.

commentsEnabled is a JSON boolean here (true / false), not the multipart string form that POST uses. A non-boolean value (e.g. the string "true") is rejected with 400 BAD_REQUEST — send a real JSON boolean.

bash
curl -X PATCH https://dropfast.dev/api/v1/sites/launch-plan-a1b2 \
  -H "Authorization: Bearer df_sk_..." \
  -H "content-type: application/json" \
  -d '{"accessMode":"password","password":"open-sesame","commentsEnabled":true}'

DELETE /api/v1/sites/{slug} — delete a site

Hard-deletes the slug and all of its files. The URL returns 404 after this.

bash
curl -X DELETE https://dropfast.dev/api/v1/sites/launch-plan-a1b2 \
  -H "Authorization: Bearer df_sk_..."

POST, DELETE /api/v1/sites/{slug}/og-image — upload or clear the social card

POST a multipart form with an image field (PNG/JPEG/WebP, ≤ 5 MB, validated by magic bytes) to host a custom social-share card; DropFast stores it content-addressed and immutable. DELETE clears it back to the auto-generated card. Both return { ogImageUrl, ogImage }. See Social sharing.

bash
curl -X POST https://dropfast.dev/api/v1/sites/launch-plan-a1b2/og-image \
  -H "Authorization: Bearer df_sk_..." \
  -F "image=@card.png"

POST /api/v1/sites/bulk — mutate many sites in one call

Apply one operation across every site matched by a selector — flip permissions on a whole project, retag, or delete in bulk. JSON body with three parts:

FieldRequiredNotes
selectoryesExactly one of filter (dotted metadata keys, same grammar as the ?metadata.* list filters — e.g. {"df.project":"acme"}) or slugs (explicit list, max 500). A filter that matches more than 500 sites returns 400 BULK_TOO_MANY_SITES.
opyes{"type":"set","set":{...}} (any of name, accessMode, password, commentsEnabled, ogImageUrl), {"type":"meta","set":{"df.type":"report"},"unset":["df.parent"]} (merge/remove keys — unlike single-site PATCH, this merges), or {"type":"delete"}.
dryRunyesExplicit boolean. true resolves the selector and validates the op without writing — always preview destructive batches first.
bash
curl -X POST https://dropfast.dev/api/v1/sites/bulk \
  -H "Authorization: Bearer df_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"selector":{"filter":{"df.project":"acme"}},"op":{"type":"set","set":{"accessMode":"private"}},"dryRun":true}'

Execution is best-effort per row: a failing site (e.g. a bogus slug in slugs) lands in results[] with its own {code, message} while the rest proceed, and the response stays 200:

json
{
  "success": true,
  "data": {
    "results": [
      { "slug": "a1b2c3d4", "ok": true },
      { "slug": "nope1234", "ok": false, "error": { "code": "NOT_FOUND", "message": "no site with slug nope1234" } }
    ],
    "summary": { "matched": 1, "changed": 1, "failed": 1 },
    "dryRun": false
  }
}

Every mutated row gets the same cache invalidation and search reindexing as a single-site PATCH/DELETE. The dropfast CLI wraps this as dropfast bulk set|meta|rm (dry-run by default, --yes to execute).

POST /api/v1/sites/{slug}/verify-password — unlock a password-protected site

Used by the password gate when a visitor enters the password. JSON or form-encoded.

bash
curl -X POST https://dropfast.dev/api/v1/sites/launch-plan-a1b2/verify-password \
  -H "content-type: application/json" \
  -d '{"password":"open-sesame"}'

On success, sets an HttpOnly cookie scoped to the slug. Subsequent requests to /s/{slug}/... from the same browser skip the gate for 24 hours. Rate-limited to 5 attempts per minute per IP.

Form-encoded posts (the built-in gate uses these) may also include an optional redirectTo field — a path beginning with /. The endpoint responds with a 303 redirect to that path after setting the cookie, so the visitor lands back on the page they tried to open. JSON posts ignore redirectTo and return the success envelope directly.

GET, POST /api/v1/keys and DELETE /api/v1/keys/{id} — manage API keys

Session-only (Clerk cookie) — these endpoints reject API-key auth (Authorization: Bearer and the legacy x-api-key), so existing keys cannot mint or revoke other keys. GET lists your active keys (prefix only — the full secret is shown once, at creation). POST mints a new key and returns the secret in the response body. DELETE revokes by id; revocation takes effect immediately.

Sharing — grants & teams

Owner-only grant management for restricted sites, plus distributed teams. Full guide: Access control.

Method & pathPurpose
GET /api/v1/sites/{slug}/grantsList a site's grants (owner only).
POST /api/v1/sites/{slug}/grantsAdd a grant — { granteeType: "email"|"domain"|"team", granteeValue }. 409 GRANT_EXISTS on duplicate, 400 INVALID_GRANTEE on a malformed email/domain.
DELETE /api/v1/sites/{slug}/grants/{grantId}Revoke a grant.
GET /api/v1/shared-with-meRestricted sites shared with the caller (email/domain/team).
GET, POST /api/v1/teamsList the caller's teams / create one (caller becomes owner).
GET, DELETE /api/v1/teams/{id}Team detail (members + granted sites) / delete (owner only).
POST /api/v1/teams/{id}/members, DELETE …?email=Add / remove a member. Nobody can remove themselves or the owner.
POST /api/v1/teams/{id}/transferOwner hands ownership to an existing member ({ email }).

Email and domain grants resolve only against a Clerk-verified email — an unverified address grants nothing. A signed-out visitor to a restricted site is redirected to sign-in and returned to the page after they authenticate.

Comments (Phase 4 spike — shape subject to change)

These three endpoints power the inline-feedback overlay that ships when a site has commentsEnabled: true. The data model is intentionally narrower than the eventual W3C annotation model — treat the response shape as not-yet-stable and avoid persisting comment IDs in agent-managed state outside a single review loop.

  • GET /api/v1/sites/{slug}/comments?status=open|resolved|all — list comments. Open by default. Owner-only on private/password sites; public on public comments-enabled sites. Version-scoped: returns only the comments left on the version being viewed. Add ?v=N to read a specific version's comments (mirrors the public read path's pin); absent reads the current version. The response also carries viewingVersion, currentVersion, and versionCounts ([{ version, count }] — open comments per version) so you can see where unresolved feedback lives.
  • POST /api/v1/sites/{slug}/comments — create one. JSON body { bodyText: string, target: { path, cssSelector?, textQuote?, x?, y? } }. Plain-text only; HTML in bodyText rejects with COMMENT_BODY_INVALID. A target with an anchor (cssSelector, textQuote, or paired x + y) is a pinned comment tied to a spot; a path-only target is a general page-level comment.
  • PATCH /api/v1/comments/{id} (or PUT, an accepted alias) — resolve or reopen. JSON body { status: 'open' | 'resolved' }. Owner-or-original-author auth. A wrong method returns a 405 with a populated Allow header. Every comment in the list/create responses also carries links.resolve / links.reopen — a ready-to-send { method, href, body } so you never have to construct this request by hand.

Comments are version-pinned: a comment belongs to the version it was left on (versionAtCreate), and only that version's comments render when a page is served at that version. A re-publish does not move or re-anchor older comments — prior versions surface through versionCounts, and you address them by reading/serving that version (?v=N).

The agent-side equivalents are the MCP get_comments, add_comment, and resolve_comment tools — see Agent handoff →.

When comments are disabled, every mutating endpoint above (and the MCP tools) return COMMENTS_SPIKE_DISABLED with a fix string pointing at update_site_settings (or PATCH with commentsEnabled: true).

Five endpoints mirror the sites surface — Authorization: Bearer auth, JSON bodies, the same envelope:

  • GET /api/v1/aliases — list yours
  • POST /api/v1/aliases — create
  • GET /api/v1/aliases/{alias} — fetch one
  • PATCH /api/v1/aliases/{alias} — rename, repoint, or pause
  • DELETE /api/v1/aliases/{alias} — delete

There is also a public resolver: GET /_/{alias} (and /_/{alias}/<sub/path>) returns a 302 redirect to the alias's target. No auth required.

bash
curl -X POST https://dropfast.dev/api/v1/aliases \
  -H "Authorization: Bearer df_sk_..." \
  -H "content-type: application/json" \
  -d '{"alias":"launch","targetType":"site","targetSiteId":"site_abc..."}'

Full request/response shape, name rules, and cascade behavior live on the Aliases page.

Response envelope

Every JSON response uses one of two shapes:

json
// success
{ "success": true, "data": { ... } }
 
// failure
{ "success": false, "error": { "code": "...", "message": "..." } }

Metadata

The metadata field on POST /api/v1/sites and PATCH /api/v1/sites/{slug}, and the ?metadata.<key>=<value> filter on GET /api/v1/sites, are always available — there are no feature flags. The metadata-specific error codes only fire on requests that send metadata, so clients that omit it are unaffected. The contract:

  • Seven reserved keys under the df. namespace: df.pr, df.repo, df.session, df.agent, df.project, df.type, df.parent. Unknown df.* keys are rejected loudly (closed-world) with METADATA_UNKNOWN_RESERVED_KEY.
  • Reserved values are string except df.type which accepts string | string[].
  • User keys (no df. prefix) are free-form, values must be string | string[].
  • 8 KB cap on JSON.stringify(metadata).length.
  • On-disk shape is nested ({"df":{"project":"X"}}), not flat.
  • Query filter uses jsonb @> containment; repeated keys are AND (array containment), not OR. Capped at 10 distinct filter keys per request.

Reading raw file content

GET /api/v1/sites/{slug}/content/{path} — fetch raw file bytes

The authenticated analog of curl https://dropfast.dev/s/{slug}/{path}. Returns a single file's raw bytes with its stored Content-Type, authorized off your API key (or Clerk session) rather than a browser cookie/password — so it reaches a private or restricted site whose /s/ origin returns an auth wall a plain fetch can't clear. The CLI wrapper is dropfast fetch.

bash
# the site index (index.html)
curl https://dropfast.dev/api/v1/sites/project-plan/content/ \
  -H "authorization: Bearer df_sk_..."
 
# a specific file, pinned to version 3
curl "https://dropfast.dev/api/v1/sites/project-plan/content/report.html?v=3" \
  -H "authorization: Bearer df_sk_..."
  • Access: public (any authenticated caller), owner (any mode), or restricted with a grant. Private and password sites are owner-only; everything a caller may not read returns 404 (never 403) so the endpoint never discloses a site's existence.
  • Path resolution matches /s/: omit the path or end it with / for the directory index (index.html); a path segment without an extension also tries {path}/index.html. Unlike /s/, a directory path here resolves in place — it never redirects to a trailing-slash form, so a scripted fetch gets bytes on the first request.
  • ?v=N pins a historical version; access is always re-checked against the site's current mode, so a pin can't bypass auth.
  • Unlike the /s/ origin, the response is never comment/beacon-injected and is served Cache-Control: private, no-store.
  • This endpoint returns the file verbatim, not the {success, data} envelope — only errors use the JSON envelope. A malformed ?v returns 400.

Version history

Every publish and every update appends an immutable version. Each publish/update response carries the version it produced plus a versionUrl permalink pinned to it (a byte-identical re-upload is a no-op that returns the current version). These endpoints are owner-scoped (your own sites only) and always available:

  • GET /api/v1/sites/{slug}/versions — list versions newest-first, with cursor pagination (?cursor=, ?limit= up to 100). Each row carries version, createdAt, author, message, byteSize, fileCount, and manifestHash.
  • GET /api/v1/sites/{slug}/versions/{v} — one version's metadata plus its file manifest. Returns VERSION_NOT_FOUND (404) for a version that doesn't exist.
  • GET /api/v1/sites/{slug}/diff?from={n}&to={m} — a source + metadata diff between two versions. Each changed file carries a status (added / removed / modified / unchanged) and, for modified text files, a hunks line-op array ({op:"="|"+"|"-", text}); metadata is a JSON delta of the df.* snapshot. Returns VERSION_NOT_FOUND (404) for an unknown version. The agent-side equivalent is the MCP get_diff tool — use it to see what changed since a comment's versionAtCreate before resolving it.
  • View the rendered bytes of a historical version in the browser at /s/{slug}/?v={v} — the versionUrl permalink. A well-formed version that doesn't exist returns 404; a malformed ?v (non-positive, non-integer, repeated) returns 400. Omit ?v to serve the current version.
bash
curl https://dropfast.dev/api/v1/sites/project-plan/versions \
  -H "Authorization: Bearer df_sk_..."

Error taxonomy

CodeHTTPWhen it firesRecovery
UNAUTHORIZED401Missing/invalid Authorization: Bearer key (or expired session)Create a fresh key, retry.
FORBIDDEN403Authenticated, but the slug belongs to a different userCheck the slug; you can only mutate your own sites.
NOT_FOUND404Slug doesn't exist (or was deleted)Verify the slug.
BAD_REQUEST400Malformed body, missing file, unknown fieldsFix the payload.
INVALID_FILE_TYPE400Upload isn't .html, .htm, .zip, or .mdRe-upload as HTML, ZIP, or Markdown.
FILE_TOO_LARGE413Upload exceeds 50 MBTrim assets or split the site.
PAYLOAD_TOO_LARGE413Markdown source exceeds 1 MBUse external image URLs or split into multiple sites.
MARKDOWN_RENDER_FAILED400The .md source failed to renderRe-check GFM tables, code-fence languages, and YAML frontmatter.
VERSION_NOT_FOUND404GET /sites/:slug/versions/:v for a version that doesn't existCall GET /sites/:slug/versions to list valid version numbers.
MISSING_INDEX400ZIP has no index.html at the root or first folderAdd one before re-uploading.
INVALID_ACCESS_MODE400accessMode not one of public, private, password, restrictedUse one of the four.
INVALID_OG_IMAGE_URL400ogImageUrl isn't an https:// URL or a single-leading-slash path (rejects http://, //, data:, javascript:, backslashes, whitespace, >2048 chars)Send an https URL, a /site-path, or null to clear. See Social sharing.
INVALID_OG_IMAGE400Uploaded OG image isn't a PNG/JPEG/WebP under 5 MB (validated by magic bytes)Upload a PNG, JPEG, or WebP under 5 MB.
INVALID_GRANTEE400Grant granteeType/granteeValue malformedUse a valid email, domain, or existing team id.
GRANT_EXISTS409That grant already exists on the siteList grants; it's already there.
PASSWORD_REQUIRED400accessMode=password with no password fieldInclude the password field.
INVALID_PASSWORD401/verify-password was called with the wrong passwordRetry with the correct one.
RATE_LIMITED429More than 5 password attempts per minute per IPWait and retry; expect ~60s.
ALIAS_ALREADY_EXISTS409The alias name is already taken (globally unique). See Aliases.Pick a different name or PATCH the existing one.
BULK_TOO_MANY_SITES400A bulk selector resolved to more than 500 sitesNarrow the filter or chunk the slugs list across calls.
INTERNAL_ERROR500Unhandled error on our sideRetry; if it persists, surface to support.
METADATA_TOO_LARGE413metadata JSON exceeds 8 KB (JSON.stringify(metadata).length)Shorten values; keep df.* keys terse; move bulky data into file content.
METADATA_UNKNOWN_RESERVED_KEY400Unknown df.* reserved key in the request body or markdown frontmatterUse one of df.pr, df.repo, df.session, df.agent, df.project, df.type, df.parent, or drop the df. prefix for free-form user keys.
METADATA_INVALID_VALUE400Value at the indicated path doesn't match the declared type (must be string or string[])Check the path in the error message; user keys are string | string[], df.* reserved keys are typed per Metadata.
METADATA_QUERY_KEY_INVALID400Query parameter has a reserved/control-char/prototype-pollution token, or a path collides with anotherDrop __proto__ / constructor / prototype segments; remove " / \ / control chars; pick one shape per key.
METADATA_QUERY_KEY_TOO_DEEP400More than 3 segments after metadata.Cap depth at 3 — metadata.<ns>.<key> is the deepest legal form.
METADATA_QUERY_EMPTY_VALUE400metadata.<key>= with no valueProvide a non-empty value, or omit the parameter.
METADATA_QUERY_TYPE_MISMATCH400Repeated values on a key whose schema type is string (e.g. ?metadata.df.pr=a&metadata.df.pr=b)Only string-or-array typed keys (e.g. df.type) accept repeated values.
METADATA_QUERY_TOO_MANY_FILTERS400More than 10 distinct keys, or more than 100 raw param entries, or serialized filter > 8 KBTrim filters to the limits above.
SEARCH_TERM_TOO_LONG400?q= longer than 200 charactersShorten the search term.
SEARCH_TERM_REQUIRED400?sort=relevance without a qAdd a q, or use sort=created/updated/name.
SORT_INVALID400?sort= not one of created, updated, name, relevanceUse one of the four.
CURSOR_INVALID400?cursor= isn't a cursor this API mintedPass the previous response's nextCursor verbatim.
CURSOR_SORT_MISMATCH400Cursor replayed under a different sort than it was minted for (incl. any cursor with sort=relevance)Restart pagination from page one under the new sort.

Limits

  • Upload: 50 MB per request.
  • Password attempts: 5/min per IP per slug.
  • No request-rate limit on the publish/list/get endpoints yet — please be reasonable.

Hosted site URLs

The url every publish response returns ends in a slash — that is the canonical form, and it is what you should share or store.

  • /s/{slug}, and any sub-directory that has an index.html (/s/{slug}/pages), answer 307 to the trailing-slash form. Relative links inside a page resolve against the directory of its URL, so serving a directory without the slash would send assets/app.css and pages/two.html outside the site.
  • The redirect is temporary and never cached — a re-upload can turn a directory into a file — and it preserves the query string, so a ?v=N pin survives.
  • An extensionless file is served as itself, not redirected.
  • Access is decided before canonicalization: a slash-less hit on a private, password, or restricted site still gets the wall.
  • Scripted callers should follow redirects, or use /content/{path}, which resolves directories in place and never redirects.

Caching

Public sites are cached at Vercel's edge for up to 24 hours with a 7-day stale-while-revalidate window. PUT /api/v1/sites/{slug} does not yet auto-invalidate viewer caches — agents that re-upload should warn the user that propagation can take up to a day, or append a cache-buster query string to the share URL. Private and password-protected sites are served with Cache-Control: private, no-store and propagate instantly. See /docs/access-control#caching.

Edit this page on GitHub