The REST API is for pipelines, scripts, and anything that is not an agent. If you are wiring up a model instead, use the MCP server — it exposes the same operations as tools over the same permission model.

Base URL

fastsite is self-hosted, so the base URL is your own engine host. Everything lives under one origin: the dashboard, the API, the MCP endpoint, and inbound webhooks.

https://your-host/api/v1

Authentication

Every request carries an API key in a header. There is no bearer token and no session on this surface — bearer auth belongs to MCP.

X-API-Key: fs_<key_id>_<secret>

A key is a 44-character string: the literal prefix fs_, an 8-character public id, an underscore, and a 32-character secret. The secret is hashed with argon2id at rest and shown exactly once, when the key is created.

php bin/fastsite key:create you@example.com \
  --name="Deploy pipeline" \
  --scopes=content:write,media:write,publish \
  --sites=blog

Use --sites=* to allow every site. Keys are also managed from the dashboard, and can be rotated or revoked at any time — a revoked key stops working immediately rather than at the end of a cache window.

Every failure looks the same

A missing header, a malformed key, an unknown key, a wrong secret, a revoked key, and a deactivated user all return the same 401 with the same message. That is deliberate — it means a key cannot be probed for existence.

The response envelope

Every JSON response has the same shape, so a client can branch on one field.

{ "success": true, "data": { … } }

{ "success": false, "error": { "message": "…" } }

List endpoints add a pagination block alongside data, and data is a plain array rather than an object:

{
  "success": true,
  "data": [ … ],
  "pagination": { "total": 42, "page": 1, "per_page": 20, "total_pages": 3 }
}

Two endpoints deliberately break the envelope, because wrapping them would be useless: reading a shell file returns the raw bytes as text/plain, and deleting one returns 204 with no body.

Create a post, then publish

curl -X POST https://your-host/api/v1/sites/blog/content \
  -H "X-API-Key: $FASTSITE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2f1c9a7e-…" \
  -d '{
    "type": "post",
    "title": "Hello, world",
    "body_html": "<p>Shipped in a single call.</p>",
    "status": "published"
  }'

Writing content does not publish it. The two are separate operations on purpose — a pipeline can create, revise, and correct as much as it needs without any of it reaching visitors. When the content is ready, publish the site:

curl -X POST https://your-host/api/v1/sites/blog/publish \
  -H "X-API-Key: $FASTSITE_KEY"
# → { "success": true, "data": { "job": "0f3c…" } }

That returns 202 with a job id; the build runs on the queue. Poll GET /api/v1/jobs/0f3c… for the outcome.

title is the only required field on a create. type defaults to post, status defaults to draft, and the slug is derived from the title when you do not send one. If that slug is taken, the engine appends a counter and returns the slug it actually used — always read it back rather than assuming.

Endpoints

Content

MethodPathScope
GET/sites/{site}/contentcontent:read
POST/sites/{site}/contentcontent:write
GET/sites/{site}/content/{id}content:read
PATCH/sites/{site}/content/{id}content:write
DELETE/sites/{site}/content/{id}content:write
GET/sites/{site}/content/trashcontent:read
POST/sites/{site}/content/{id}/restorecontent:write
DELETE/sites/{site}/content/{id}/purgecontent:write
POST/sites/{site}/content/{id}/fact-checkcontent:read

Deleting is a soft delete — the item moves to trash and can be restored. purge is the irreversible one.

Media

MethodPathScope
GET/sites/{site}/mediamedia:read
POST/sites/{site}/mediamedia:write
DELETE/sites/{site}/media/{id}media:write
POST/sites/{site}/media/{id}/restoremedia:write
DELETE/sites/{site}/media/{id}/purgemedia:write

Upload accepts either a multipart form with a field named file, or JSON with a url to fetch. Not both — if a file is present the URL is ignored.

curl -X POST https://your-host/api/v1/sites/blog/media \
  -H "X-API-Key: $FASTSITE_KEY" \
  -F file=@hero.jpg \
  -F alt="Sunrise over the harbour"

Alt text is required for images unless you explicitly pass decorative=true. That is not a style preference — a missing alt fails the build audit, so the API refuses it at the door instead of letting it break a publish later. URL imports are SSRF-guarded: private address ranges are rejected, redirects are re-validated, and the download is byte-capped.

Filter the library with ?kind=image,video,file. Images and video run through the variant pipeline; anything else is stored as a downloadable file.

Shell

MethodPathScope
GET/sites/{site}/shell/filesshell:read
GET/sites/{site}/shell/file/{path}shell:read
PUT/sites/{site}/shell/file/{path}shell:write
DELETE/sites/{site}/shell/file/{path}shell:write
POST/sites/{site}/shell/snapshotshell:write
POST/sites/{site}/shell/restore/{rev}shell:write

The body is the file, not JSON. There is no wrapper object and no field name — you send the bytes and you get the bytes back.

curl -X PUT https://your-host/api/v1/sites/blog/shell/file/templates/base.html.twig \
  -H "X-API-Key: $FASTSITE_KEY" \
  --data-binary @base.html.twig

The path may contain slashes. Writes are constrained to the shell directory, capped at 5 MB, and limited to an extension allowlist (twig, css, js, json, woff2, svg, and the usual image types). Unlike content, a shell write does queue a rebuild — the design changed, so the output has to. A burst of edits coalesces into one build rather than one per file.

Snapshot before a risky change. Restoring takes a snapshot first, so a restore is itself undoable.

Components, redirects, scripts

MethodPathScope
PUT/sites/{site}/components/{handle}components:write
GET · POST/sites/{site}/redirectssites:read · sites:write
DELETE/sites/{site}/redirects/{id}sites:write
GET · POST/sites/{site}/scriptssites:read · sites:write
DELETE/sites/{site}/scripts/{id}sites:write

A component is {type: "html"|"react", source_code, css?, variables_schema?}. React compiles at upload, so a broken component is a 422 on your request rather than a broken page later.

Languages, publishing, sites

MethodPathScope
POST/sites/{site}/languagestranslations:manage
DELETE/sites/{site}/languages/{locale}translations:manage
POST/sites/{site}/publishpublish
GET/jobs/{uuid}jobs:read
GET/sitessites:read
POST/sitesadmin
PATCH/sites/{site}sites:write
GET · POST/sites/{site}/webhookssites:read · sites:write
GET/sites/{site}/webhooks/{id}/deliveriessites:read
DELETE/sites/{site}/webhooks/{id}sites:write
GET/authoring-guidesites:read
GET/sites/{site}/authoring-guidesites:read

Fetch the authoring guide first

GET /api/v1/authoring-guide returns the machine-readable rules for body HTML, image embedding, and the shell contract. Pass a site to get its locales and embed syntax folded in. It is the difference between content that builds and content the audit rejects.

Scopes

Keys and OAuth grants share one scope vocabulary, so an endpoint and a tool enforce access identically.

ScopeGrants
sites:readSee sites and their settings
sites:writeChange site settings, redirects, scripts, webhooks
content:readRead posts and pages
content:writeCreate and edit posts and pages
media:readView the media library
media:writeUpload images, video, and files
components:writeCreate and update components
shell:readRead templates and theme
shell:writeEdit templates and theme
translations:manageManage locales and translations
publishBuild and publish
jobs:readRead build and job status
backup:read · backup:run · backup:restoreBackup operations
adminEverything

Only admin implies anything else. There is no read/write hierarchy — content:write does not grant content:read, so ask for both if you need both. Keys are also pinned to a site list, and a request for a site outside it is rejected before it reaches a handler.

Idempotency

Send an Idempotency-Key header on any POST and the engine records the outcome against it.

  • The same key with the same method, path, and body replays the stored response, with Idempotency-Replayed: true on it.
  • The same key with a different body is a 422 — it is how you find out your retry logic changed the payload.
  • A retry that arrives while the first is still running gets 409 and a Retry-After.
  • If the handler throws, or the response is a 5xx or a 429, the key is released so a genuine retry can succeed.

Other methods ignore the header entirely.

Rate limits

Two fixed-window limits, both per minute: 60 requests per IP and 120 per API key. Exceeding either returns 429 with a Retry-After. The per-IP bucket is the one you will usually meet first, since a single client is one address.

JSON request bodies are capped at 4 MiB. Uploads are exempt from that check and bounded by the per-type media limits instead.

Status codes

CodeMeaning
200 · 201 · 202 · 204Success. 202 means queued — poll the job.
401Missing, malformed, unknown, or revoked key.
403Valid key, but the scope or the site is not allowed.
404No such route, site, or record.
409Duplicate, or an idempotent request still in flight.
413Body over the limit.
422Validation failed — read error.message.
429Rate limited.

Details worth knowing before you build against it

  • Timestamps are strict. scheduled_at and published_at must be YYYY-MM-DDTHH:MM:SSZ — UTC, uppercase T, trailing Z. Offsets like +02:00 are rejected rather than converted.
  • Status values are draft, scheduled, published, and archived. Scheduling requires scheduled_at, and the scheduler publishes it for you when the time comes.
  • Paging is ?page= and ?per_page=, defaulting to 20 and capped at 100.
  • Body HTML is sanitized on every path. Inline styles are stripped, scripts and iframes are removed, and the result comes back with a warnings array. Content problems never fail your request — they are reported, normalised, and published.
  • Some things are not on REST. Backups, teams, and API-key management are CLI and dashboard operations; backups and restores are also available over MCP.

Next: the MCP server for agent-driven publishing, or webhooks for pushing content in from an upstream system.