Reference

REST API

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

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, or password. Agent SDKs and the dropfast-publish skill default to private — see /docs/agents.
passwordcond.stringRequired when accessMode is password.
commentsEnablednostring"true" to enable the inline comments overlay on the new site, "false" (default) to disable. Multipart only accepts strings; any value other than "true"/"false" is ignored and falls back to false.
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 your sites

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

Returns 200 with data as an array of sites.

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

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

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, and/or commentsEnabled. Does not touch files. Omit any field to leave it unchanged.

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

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.
  • 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. target must include path plus at least one real anchor (cssSelector, textQuote, or paired x + y).
  • PATCH /api/v1/comments/{id} — resolve or reopen. JSON body { status: 'open' | 'resolved' }. Owner-or-original-author auth.

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.

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.
  • 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, passwordUse one of the three.
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.
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 (preview).
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.

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.

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