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.
The OpenAPI document
Section titled “The OpenAPI document”curl -s https://api.menestrel.dev/v1/openapi.jsonNo authentication required. Import it into Bruno, Postman or a client generator: every route describes its parameters, request bodies and responses.
Authentication
Section titled “Authentication”Content routes expect a project token in the Authorization header, in Bearer format:
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.
Published content: what the loader reads
Section titled “Published content: what the loader reads”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.
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.
Entries
Section titled “Entries”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.
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:
POST /v1/projects/{projectId}/assets/uploadsdeclares the files (filename,mime,bytes) and returns one presigned upload URL per file, with theContent-Typeheader to reuse.PUTeach file to itsuploadUrl, straight to storage.POST /v1/projects/{projectId}/assets/{assetId}/completechecks the received object and starts processing:202while the asset is being processed,200once 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.
Schema
Section titled “Schema”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.
Publication and deploy targets
Section titled “Publication and deploy targets”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.
Error format
Section titled “Error format”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 avalidation_failed(400).
Rate limits
Section titled “Rate limits”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.