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:
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:
| Field | Required | Type | Notes |
|---|---|---|---|
file | yes | file | .html, .htm, or .zip. Max 50 MB. |
name | no | string | Display name. Used to seed the slug. |
accessMode | no | enum | public (API default), private, or password. Agent SDKs and the dropfast-publish skill default to private — see /docs/agents. |
password | cond. | string | Required when accessMode is password. |
commentsEnabled | no | string | "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. |
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
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
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.
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.
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.
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.
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 inbodyTextrejects withCOMMENT_BODY_INVALID.targetmust includepathplus at least one real anchor (cssSelector,textQuote, or pairedx+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).
/api/v1/aliases — short-link CRUD
Five endpoints mirror the sites surface — Authorization: Bearer auth, JSON bodies, the
same envelope:
GET /api/v1/aliases— list yoursPOST /api/v1/aliases— createGET /api/v1/aliases/{alias}— fetch onePATCH /api/v1/aliases/{alias}— rename, repoint, or pauseDELETE /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.
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:
// 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. Unknowndf.*keys are rejected loudly (closed-world) withMETADATA_UNKNOWN_RESERVED_KEY. - Reserved values are
stringexceptdf.typewhich acceptsstring | string[]. - User keys (no
df.prefix) are free-form, values must bestring | 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 carriesversion,createdAt,author,message,byteSize,fileCount, andmanifestHash.GET /api/v1/sites/{slug}/versions/{v}— one version's metadata plus its file manifest. ReturnsVERSION_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}— theversionUrlpermalink. A well-formed version that doesn't exist returns 404; a malformed?v(non-positive, non-integer, repeated) returns 400. Omit?vto serve the current version.
curl https://dropfast.dev/api/v1/sites/project-plan/versions \
-H "Authorization: Bearer df_sk_..."Error taxonomy
| Code | HTTP | When it fires | Recovery |
|---|---|---|---|
UNAUTHORIZED | 401 | Missing/invalid Authorization: Bearer key (or expired session) | Create a fresh key, retry. |
FORBIDDEN | 403 | Authenticated, but the slug belongs to a different user | Check the slug; you can only mutate your own sites. |
NOT_FOUND | 404 | Slug doesn't exist (or was deleted) | Verify the slug. |
BAD_REQUEST | 400 | Malformed body, missing file, unknown fields | Fix the payload. |
INVALID_FILE_TYPE | 400 | Upload isn't .html, .htm, .zip, or .md | Re-upload as HTML, ZIP, or Markdown. |
FILE_TOO_LARGE | 413 | Upload exceeds 50 MB | Trim assets or split the site. |
PAYLOAD_TOO_LARGE | 413 | Markdown source exceeds 1 MB | Use external image URLs or split into multiple sites. |
MARKDOWN_RENDER_FAILED | 400 | The .md source failed to render | Re-check GFM tables, code-fence languages, and YAML frontmatter. |
VERSION_NOT_FOUND | 404 | GET /sites/:slug/versions/:v for a version that doesn't exist | Call GET /sites/:slug/versions to list valid version numbers. |
MISSING_INDEX | 400 | ZIP has no index.html at the root or first folder | Add one before re-uploading. |
INVALID_ACCESS_MODE | 400 | accessMode not one of public, private, password | Use one of the three. |
PASSWORD_REQUIRED | 400 | accessMode=password with no password field | Include the password field. |
INVALID_PASSWORD | 401 | /verify-password was called with the wrong password | Retry with the correct one. |
RATE_LIMITED | 429 | More than 5 password attempts per minute per IP | Wait and retry; expect ~60s. |
ALIAS_ALREADY_EXISTS | 409 | The alias name is already taken (globally unique). See Aliases. | Pick a different name or PATCH the existing one. |
INTERNAL_ERROR | 500 | Unhandled error on our side | Retry; if it persists, surface to support. |
METADATA_TOO_LARGE | 413 | metadata JSON exceeds 8 KB (JSON.stringify(metadata).length) | Shorten values; keep df.* keys terse; move bulky data into file content. |
METADATA_UNKNOWN_RESERVED_KEY | 400 | Unknown df.* reserved key in the request body or markdown frontmatter | Use 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_VALUE | 400 | Value 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_INVALID | 400 | Query parameter has a reserved/control-char/prototype-pollution token, or a path collides with another | Drop __proto__ / constructor / prototype segments; remove " / \ / control chars; pick one shape per key. |
METADATA_QUERY_KEY_TOO_DEEP | 400 | More than 3 segments after metadata. | Cap depth at 3 — metadata.<ns>.<key> is the deepest legal form. |
METADATA_QUERY_EMPTY_VALUE | 400 | metadata.<key>= with no value | Provide a non-empty value, or omit the parameter. |
METADATA_QUERY_TYPE_MISMATCH | 400 | Repeated 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_FILTERS | 400 | More than 10 distinct keys, or more than 100 raw param entries, or serialized filter > 8 KB | Trim 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.