Skip to content

HTTP API reference

Everything the admin, the CLI and the loader do goes through the same REST API. Anything you can click, you can script.

  • Base URL: https://api.menestrel.dev
  • Prefix: every route starts with /v1
  • Format: JSON in, JSON out

This page documents the patterns: authentication, the main endpoint groups, the error format, the rate limits. The exhaustive route list lives in the OpenAPI document, generated from the server code, so it can never lag behind it.

Fenêtre de terminal
curl -s https://api.menestrel.dev/v1/openapi.json

No authentication required. Import it into Bruno, Postman or a client generator: every route describes its parameters, request bodies and responses.

Content routes expect a project token in the Authorization header, in Bearer format:

Fenêtre de terminal
curl -s https://api.menestrel.dev/v1/tokens/me \
-H "Authorization: Bearer cmt_adm_xxx"

A token belongs to a single project and carries a scope (read_published, read_draft or admin) that bounds what it can do. GET /v1/tokens/me returns the identity of the current token: handy to check a .env without touching anything. Scopes and their lifecycle are covered in API tokens and security.

Three failure responses, deliberately short on detail:

Status Code Situation
401 auth.missing_token No Authorization: Bearer header.
401 auth.invalid_token Unknown, revoked or expired token: same response in all three cases.
403 auth.insufficient_scope The token scope is not enough for this route.

A valid token targeting a project other than its own gets 404 project.not_found, never a 403: the API does not confirm the existence of what it will not show.

GET /v1/projects/{projectId}/snapshot/latest returns the pointer to the latest published snapshot. It is the route the loader uses in MENESTREL_TOKEN mode; it accepts every scope, read_published is enough.

Fenêtre de terminal
curl -s https://api.menestrel.dev/v1/projects/prj_xxx/snapshot/latest \
-H "Authorization: Bearer cmt_pk_xxx"
{
"snapshot_id": "snap_xxx",
"published_at": "2026-07-08T14:03:21.000Z",
"content_base_url": "https://content.menestrel.dev/p/prj_xxx/pk_xxx",
"manifest": { "manifest_format": 1, "...": "..." }
}

The content itself is not served by the API: content_base_url points to the CDN, where every snapshot is immutable. That decoupling is what lets a build pass even when the application is down; see the loader reference. If nothing has ever been published, the route answers 404 snapshot.none_published.

GET /v1/projects/{projectId}/snapshots/{snapshotId}/{collection}.{locale}.json redirects (302) to the collection file on the CDN, for a specific snapshot.

The routes live under /v1/projects/{projectId}/entries. Via token, they require the admin scope, reads included.

Route Purpose
GET /entries Cursor-paginated list; collection, status, search filters.
POST /entries Creates an entry: collection_key, optional values.
GET /entries/{entryId} Entry detail.
PUT /entries/{entryId}/draft Saves a draft: values, base_version.
POST /entries/{entryId}/publish Publishes one or more locales.
POST /entries/{entryId}/duplicate Duplicates an entry.
DELETE /entries/{entryId} Moves to trash (purged after 30 days).
POST /entries/{entryId}/restore Restores from trash.
GET /entries/{entryId}/versions Version history.
POST /entries/order Reorders a manually sorted collection.

Example: publish the fr locale of an entry.

Fenêtre de terminal
curl -s -X POST https://api.menestrel.dev/v1/projects/prj_xxx/entries/ent_xxx/publish \
-H "Authorization: Bearer cmt_adm_xxx" \
-H "Content-Type: application/json" \
-d '{ "locales": ["fr"] }'
{
"version": 4,
"locales_status": { "fr": "published" },
"entry_status": "published",
"published_at": "2026-07-10T09:12:45.000Z",
"unchanged": false
}

If nothing changed since the last publication, unchanged is true and no snapshot is rebuilt. For a bulk content migration, add "via": "import" to the body of POST /entries and /publish: the created versions are marked as imported and the creation limit rises to 2,000 per hour. This is exactly what menestrel import does, and it remains the recommended path.

Uploads happen in three steps, and the API never sees the bytes:

  1. POST /v1/projects/{projectId}/assets/uploads declares the files (filename, mime, bytes) and returns one presigned upload URL per file, with the Content-Type header to reuse.
  2. PUT each file to its uploadUrl, straight to storage.
  3. POST /v1/projects/{projectId}/assets/{assetId}/complete checks the received object and starts processing: 202 while the asset is being processed, 200 once it is ready.

Around that flow: GET /assets (list, search, folder filter), PATCH /assets/{assetId} (filename, alt text, focal point, folder), DELETE and POST .../restore (trash), POST .../crop (cropping), folders under /asset-folders, and GET /storage for the quota.

The schema is defined in code and pushed with menestrel sync, which calls POST /v1/projects/{projectId}/schema/diff then POST .../schema/sync. GET .../schema returns the active version, GET .../schema/versions the history. In practice, go through the CLI: it handles the diff, the confirmation and the migrations. See the CLI reference.

Deploy targets (Vercel, Netlify and Cloudflare Pages webhooks, GitHub repository_dispatch) are managed under /v1/projects/{projectId}/deploy-targets: create, update, test, resume after repeated failures. GET /v1/projects/{projectId}/deployments lists the latest triggers with their state, and POST /v1/projects/{projectId}/snapshots/{snapshotId}/restore puts an earlier snapshot back live.

These routes are session-driven from the admin; they are not reachable with a project token. The product-side behavior is described in Publication.

Every error, 500 included, shares the same envelope:

{
"error": {
"code": "validation_failed",
"message": "Validation failed",
"details": [{ "path": ["locales"], "message": "..." }]
}
}
  • code: stable, made to be tested by a machine. Namespaced by domain (auth.invalid_token, snapshot.not_found, media.asset_in_use) or global (validation_failed, rate_limited, internal).
  • message: human-readable, in English, may change. Never test against it.
  • details: present when there is more to say, absent otherwise. Example: the field-by-field validation errors of a validation_failed (400).

Limits apply per project and per time window. The main ones:

Action Limit
Entry publish 30 / minute
Draft save 60 / minute, per entry and per author
Entry create or duplicate 120 / hour
Create in import mode ("via": "import") 2,000 / hour
Entry list 600 / hour
Media upload declarations 300 files / hour
schema/diff 120 / hour
schema/sync 30 / hour
Token creation 10 / hour
Snapshot restore 12 / hour

Past the limit, the API answers 429 with the rate_limited code and a Retry-After header in seconds: wait that long, then replay the request.