EmDash exposes a REST API at /_emdash/api/ for content management, media uploads, and schema operations.
Authentication
API requests require authentication via a Bearer token in the Authorization header:
Authorization: Bearer <token>
Generate tokens through the admin interface or programmatically.
Response format
All responses follow a consistent format. A successful response wraps the result in data:
{
"success": true,
"data": { ... }
}
An error response includes a code, message, and optional details:
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable message",
"details": { ... }
}
}
Content Endpoints
List Content
GET /_emdash/api/content/:collection
Parameters
| Parameter | Type | Description |
|---|---|---|
collection | string | Collection slug (path) |
cursor | string | Pagination cursor (query) |
limit | number | Items per page (query, default: 50) |
status | string | Filter by status (query) |
orderBy | string | Field to sort by (query) |
order | string | Sort direction: asc or desc (query) |
Response
{
"success": true,
"data": {
"items": [
{
"id": "01HXK5MZSN...",
"type": "posts",
"slug": "hello-world",
"data": { "title": "Hello World", ... },
"status": "published",
"createdAt": "2025-01-24T12:00:00Z",
"updatedAt": "2025-01-24T12:00:00Z"
}
],
"nextCursor": "eyJpZCI6..."
}
}
Get Content
GET /_emdash/api/content/:collection/:id
Response
{
"success": true,
"data": {
"item": {
"id": "01HXK5MZSN...",
"type": "posts",
"slug": "hello-world",
"data": { "title": "Hello World", ... },
"status": "published",
"createdAt": "2025-01-24T12:00:00Z",
"updatedAt": "2025-01-24T12:00:00Z"
}
}
}
Create Content
POST /_emdash/api/content/:collection
Content-Type: application/json
Request Body
{
"data": {
"title": "New Post",
"content": [...]
},
"slug": "new-post",
"status": "draft"
}
Response
{
"success": true,
"data": {
"item": { ... }
}
}
Update Content
PUT /_emdash/api/content/:collection/:id
Content-Type: application/json
Request Body
{
"data": {
"title": "Updated Title"
},
"status": "published"
}
Delete Content
DELETE /_emdash/api/content/:collection/:id
Response
{
"success": true,
"data": {
"success": true
}
}
Media Endpoints
List Media
GET /_emdash/api/media?includeUsage=1
Parameters
| Parameter | Type | Description |
|---|---|---|
cursor | string | Opaque pagination cursor |
page | number | Numbered page, starting at 1; cannot be combined with cursor |
limit | number | Items per page, from 1 to 100 (default: 50) |
mimeType | string | Filter by one or more comma-separated MIME types |
q | string | Case-insensitive filename search |
folderId | string | Folder ID, or unfiled for the Main library |
includeUsage | 1 | Include a coverage-aware usage summary on every returned item |
Omit folderId to list media from the Main library and every folder. Use folderId=unfiled to
list only media that is not assigned to a folder. A numbered request returns totalCount; cursor
mode returns nextCursor when another page is available.
Response
{
"success": true,
"data": {
"items": [
{
"id": "01HXK5MZSN...",
"filename": "photo.jpg",
"mimeType": "image/jpeg",
"size": 102400,
"width": 1920,
"height": 1080,
"folderId": null,
"url": "/_emdash/api/media/file/uploads/photo.jpg",
"createdAt": "2025-01-24T12:00:00Z",
"usage": {
"count": 3,
"coverage": {
"scope": "all_content_collections",
"status": "complete"
}
}
}
],
"nextCursor": "eyJpZCI6..."
}
}
Get Media
GET /_emdash/api/media/:id?includeUsage=1
includeUsage is optional on both list and get. Its only accepted value is 1. When omitted,
the usage property is omitted and the server does not run usage queries.
Usage Summaries
usage.count is the number of distinct active EmDash content rows or locales whose selected
current indexed source references the media item. Repeated references and multiple source
variants for the same content entry count once. Trashed content does not count.
A numeric count can reveal draft-like content. It is returned only when a session user has
content:read_drafts, or when an API token has admin scope and its associated user also has
that permission. Other media readers receive usage.count: null; this is a successful redacted
response, not an error.
Every requested summary includes aggregate coverage for all currently registered content collections:
| Status | Meaning |
|---|---|
complete | Every registered collection has current, completed usage coverage |
never | No registered collection has completed an initial usage repair |
running | A usage repair is currently running |
partial | Coverage is mixed or only part of the registered scope was indexed |
failed | Coverage failed across the registered scope |
stale | Indexed coverage is outdated |
unknown | Stored coverage contains a state this version does not recognize |
Only complete supports a scoped complete-zero statement within the supported fields
described below. Counts with any other status are indexed projections and may over-report or
under-report. Even complete results are advisory during concurrent writes; usage reads are not a
transactional lock and must not be used as a deletion guarantee.
Get media usage details
GET /_emdash/api/media/:id/usage?limit=50&cursor=...
This endpoint requires media:read and content:read_drafts. Token-authenticated callers also
require admin scope; token scope does not bypass the associated user’s permissions.
limit controls content entry groups per page, from 1 to 100 (default: 50). Pagination never
splits the sources or occurrences for one returned entry group.
{
"data": {
"items": [
{
"collection": "posts",
"contentId": "01CONTENT...",
"title": "Launch notes",
"slug": "launch-notes",
"locale": "en",
"status": "published",
"scheduledAt": null,
"deletedAt": null,
"sources": [
{
"variant": "columns",
"occurrences": [
{
"fieldSlug": "hero",
"fieldPath": "hero",
"occurrenceIndex": 0,
"referenceType": "image_field"
}
]
}
]
}
],
"nextCursor": "eyJvcmRlclZhbHVlIjoicG9zdHMiLCJpZCI6IjAxLi4uIn0",
"coverage": {
"scope": "all_content_collections",
"status": "complete"
}
}
}
Authorized details include active and trashed entries. A non-null deletedAt identifies a
trashed entry. Sources are columns or draft_overlay; occurrences identify the supported field
and path without exposing internal index metadata.
Media usage covers local media references in top-level image and file fields, repeater image fields, and Portable Text image blocks stored in EmDash content collections. It does not scan custom code, rendered HTML, settings, menus, widgets, plugin-private data, external sites, or provider-only assets.
Upload Media
Uploading media requires media:upload. Bearer tokens also require the media:write scope. The
default maximum file size is 50 MB. Set
maxUploadSize to change the limit.
Use one of the following upload methods:
- Use a direct multipart upload when the client can send the file through EmDash.
- Use the upload target flow for direct uploads to S3-compatible storage. Local storage and native R2 return an EmDash upload URL instead.
Direct multipart upload
Send the file in the file field of a multipart request:
curl --request POST \
--header "Authorization: Bearer $EMDASH_TOKEN" \
--header "X-EmDash-Request: 1" \
--form "file=@./photo.jpg;type=image/jpeg" \
https://example.com/_emdash/api/media
curl adds the multipart boundary to the Content-Type header. Do not set that header manually.
The endpoint also accepts the following optional multipart fields:
| Field | Description |
|---|---|
width | Image width in pixels |
height | Image height in pixels |
fieldId | Field whose configured MIME type allowlist applies to the upload |
thumbnail | Downscaled image used to generate a low-quality image placeholder |
A new upload returns 201 Created with the stored media item and folderId: null. If the same
file already exists, EmDash returns 200 OK with deduplicated: true, the existing media item,
and its current folder assignment. Direct multipart uploads are ready immediately and do not use
the confirmation endpoint.
Upload target flow
The upload target flow keeps the media item in pending state until the final confirmation.
Pending media does not appear in the standard media list or media library.
-
Request an upload target
POST /_emdash/api/media/upload-url Authorization: Bearer <token> Content-Type: application/json { "filename": "photo.jpg", "contentType": "image/jpeg", "size": 102400 }filename,contentType, andsizeare required. The request also accepts the following optional fields:Field Description contentHashsha1:plus 40 lowercase hexadecimal characters, used to find a matchfieldIdField whose configured MIME type allowlist applies to the upload The response contains the URL, method, and headers for the file upload.
uploadUrlis an absolute signed URL when the storage adapter supports one. Otherwise, it is a root-relative EmDash endpoint.{ "success": true, "data": { "uploadUrl": "/_emdash/api/media/01M0AFKJS0RJM3WV69QHAY7YA1/upload", "method": "PUT", "headers": { "Content-Type": "image/jpeg", "X-EmDash-Request": "1" }, "mediaId": "01M0AFKJS0RJM3WV69QHAY7YA1", "storageKey": "01M0AFKJS0K2YF0222NP6ENYWX.jpg", "expiresAt": "2026-08-18T14:05:09.920Z" } }If
contentHashmatches an existing media item with the same MIME type and size, the response containsexisting: true,mediaId,storageKey, andurlinstead of an upload target. Use the returned media item and stop. Do not upload or confirm the file. -
Upload the file to
uploadUrlStart with the returned
methodandheaders. Resolve a relativeuploadUrlagainst the EmDash site URL.For a same-origin EmDash target, include the Bearer token in the upload request. For a signed URL on another origin, send only the returned upload headers.
A same-origin upload returns the following response. A signed URL returns the storage provider’s response instead.
{ "success": true, "data": { "uploaded": true, "size": 102400 } } -
Confirm the upload
Confirming checks the stored file and changes the media item from
pendingtoready.size,width, andheightare optional, but EmDash validates them when supplied.POST /_emdash/api/media/01M0AFKJS0RJM3WV69QHAY7YA1/confirm Authorization: Bearer <token> Content-Type: application/json { "size": 102400, "width": 1920, "height": 1080 }The response contains the ready media item:
{ "success": true, "data": { "item": { "id": "01M0AFKJS0RJM3WV69QHAY7YA1", "filename": "photo.jpg", "status": "ready", "url": "/_emdash/api/media/file/01M0AFKJS0K2YF0222NP6ENYWX.jpg" } } }
Upload errors
| Status | Code | Cause |
|---|---|---|
400 | NO_FILE | The multipart request has no file field, or the upload body is missing |
400 | INVALID_TYPE | The MIME type is not allowed or does not match the pending media item |
400 | VALIDATION_ERROR | Upload metadata is missing, invalid, or exceeds the configured size limit |
400 | FILE_NOT_FOUND | Confirmation cannot find the uploaded object |
400 | UPLOAD_SIZE_MISMATCH | The declared, uploaded, and confirmed sizes do not match |
400 | INVALID_STATE | The media item is not pending |
404 | NOT_FOUND | The media item does not exist |
409 | INVALID_STATE | The pending media item changed during confirmation |
413 | PAYLOAD_TOO_LARGE | The direct or same-origin upload is too large |
Update Media
PUT /_emdash/api/media/:id
Content-Type: application/json
Request Body
{
"alt": "Photo description",
"caption": "Photo caption",
"folderId": "01FOLDER..."
}
Omit folderId to leave the current assignment unchanged. Set it to null or unfiled to return
the media item to the Main library. Assigning owned media requires media:edit_own; assigning any
media requires media:edit_any. Bearer tokens also require the media:write scope.
List Media Folders
GET /_emdash/api/media/folders?limit=50&q=product&cursor=...
| Parameter | Type | Description |
|---|---|---|
cursor | string | Opaque pagination cursor |
limit | number | Folders per page, from 1 to 100 (default: 50) |
q | string | Case-insensitive partial folder-name search (1–200 characters) |
Returns folders in name order with an optional nextCursor. The endpoint requires media:read.
Get Media Folder
GET /_emdash/api/media/folders/:id
Returns the folder with the requested ID. The endpoint requires media:read and returns 404 when
the folder does not exist.
Create Media Folder
POST /_emdash/api/media/folders
Content-Type: application/json
{
"name": "Product photos"
}
Returns 201 Created with the created folder:
{
"success": true,
"data": {
"item": {
"id": "01HXK5MZSN...",
"name": "Product photos"
}
}
}
Folder names are trimmed and must contain 1 to 200 characters. Names are compared after Unicode
normalization and lowercasing, so Photos, photos, and PHOTOS are treated as duplicates.
Creating, renaming, and deleting folders requires media:edit_any. Bearer tokens also require the
media:write scope.
Rename Media Folder
PUT /_emdash/api/media/folders/:id
Content-Type: application/json
{
"name": "Published product photos"
}
Returns 200 OK with the updated folder in the same response shape as creation.
Delete Media Folder
DELETE /_emdash/api/media/folders/:id
Returns 200 OK with { "deleted": true } in the response data.
Deleting a folder returns its media to the Main library. It does not delete media, change media IDs or URLs, or change media usage records.
Folder errors
| Status | Code | Cause |
|---|---|---|
400 | INVALID_CURSOR | The folder-list cursor is invalid |
400 | VALIDATION_ERROR | A folder name, folder ID, or list parameter is invalid |
404 | NOT_FOUND | A folder or folder assignment target does not exist |
409 | CONFLICT | A folder already has the same normalized name |
Delete Media
DELETE /_emdash/api/media/:id
Enable media usage tracking
Sites with media usage tracking off must turn it on once. Pause direct database writes while EmDash prepares each collection. EmDash temporarily blocks its own content and schema writes during this step.
Both endpoints require schema:manage. Bearer tokens also require the admin scope.
For the admin procedure, see Turn on media usage tracking. The endpoints below provide the same procedure for API operators.
Check the current state
GET /_emdash/api/admin/media-usage/activation
This request does not change anything. It returns one of these states:
expanded: media usage tracking is off.activating: EmDash is preparing the site’s collections.active: EmDash tracks changes to media references in content.
Status responses do not include internal lock data or raw database errors.
Start activation
POST /_emdash/api/admin/media-usage/activation
Content-Type: application/json
X-EmDash-Request: 1
{
"writersDrained": true
}
The request prepares at most one collection. After it succeeds, advance setup and historical indexing through the progress endpoint below.
Set writersDrained to true after application and direct database writes have stopped and any writes already in progress have finished.
Enable tracking with the API
- Stop all direct database writers. Wait for writes already in progress to finish. EmDash fences its own writes during setup.
- Call the activation
GETendpoint to check the current state. - Call the activation
POSTendpoint once withwritersDrained: true. - Call the progress
POSTendpoint serially, followingnextRequestInMs, until activation becomesactive. - Resume direct database writes after activation becomes
active. - Continue progress requests until historical indexing reports
readyandnextRequestInMsisnull.
If POST times out or returns 409 or 500, call GET before deciding what to do. If the state is
still activating without lastErrorCode, another request may own the current batch. If lastErrorCode is set, keep writes stopped, check the application logs, fix the
problem, and send one confirmed POST to retry. Do not edit EmDash’s internal database tables.
When the state is active, EmDash tracks changes to media references in content. Existing content may still need progress requests before historical indexing is ready.
Check historical indexing progress
GET /_emdash/api/admin/media-usage/progress
After activation is active, this returns indexing, ready, or needs_attention together with the
number of ready and total current content types. The endpoint does not inspect content rows or return
work-item details. It requires schema:manage; bearer tokens also require the admin scope.
Advance setup and historical indexing
POST /_emdash/api/admin/media-usage/progress
X-EmDash-Request: 1
The request has no body. It runs one bounded maintenance step and returns the stored activation and progress state after that step.
{
"success": true,
"data": {
"activation": {
"state": "active",
"collectionCursor": null,
"attemptCount": 2,
"drainConfirmedAt": "2026-08-24T12:00:00.000Z",
"lastAttemptedAt": "2026-08-24T12:00:01.000Z",
"lastErrorCode": null,
"leaseExpiresAt": null,
"activatedAt": "2026-08-24T12:00:01.000Z",
"updatedAt": "2026-08-24T12:00:02.000Z"
},
"progress": {
"status": "indexing",
"readyCollections": 1,
"totalCollections": 2
},
"nextRequestInMs": 0
}
}
progress is null until activation is active. nextRequestInMs is 0 for an immediate successor, 30000 for a delayed retry, or null when the server knows of no successor. Send only one progress request at a time and wait for the returned delay.
Closing the client does not discard completed work, but it stops future requests. To resume, read activation first, read progress when activation is active, then continue progress requests. After an ambiguous response, perform the same reads before retrying.
List media usage work
GET /_emdash/api/admin/media-usage/work?collection=posts&state=failed&limit=50&cursor=...
Returns a bounded page of durable entry-indexing work for one current collection. The endpoint
requires schema:manage; bearer tokens also require the admin scope.
collection is required. state optionally filters pending, retry, leased, or failed
work. limit defaults to 50 and is capped at 100. cursor is opaque and comes from the previous
page’s nextCursor. The endpoint does not calculate an exact backlog count.
{
"success": true,
"data": {
"items": [
{
"collectionId": "01COLLECTION...",
"collectionSlug": "posts",
"contentId": "01CONTENT...",
"state": "failed",
"attemptCount": 5,
"nextAttemptAt": "2026-08-07T12:00:00.000Z",
"leaseExpiresAt": null,
"lastAttemptedAt": "2026-08-07T11:45:00.000Z",
"lastErrorCode": "MEDIA_USAGE_PROCESSING_FAILED",
"updatedAt": "2026-08-07T11:45:00.000Z"
}
],
"nextCursor": "eyJvcmRlclZhbHVlIjoiLi4uIn0"
}
}
Responses omit work versions, lease tokens, raw database errors, indexed content, media references, and exact counts.
Retry media usage work
POST /_emdash/api/admin/media-usage/work/retry
Content-Type: application/json
X-EmDash-Request: 1
Idempotently reopens or creates one durable entry job. It has the same authorization requirements as the list endpoint.
{
"collectionId": "01COLLECTION...",
"contentId": "01CONTENT..."
}
A successful response returns changed and the current pending item. changed: false means the
job was already pending. A non-expired worker lease returns 409 WORK_LEASE_ACTIVE with
details.leaseExpiresAt; a concurrent mutation returns 409 WORK_CHANGED. Neither conflict
replaces newer work or exposes its lease token.
The list returns only known durable work. Retry can create work for the supplied identity in an
active collection even when no work row exists, but it does not scan for historical gaps. Use
collection-scoped media usage repair after imports or direct database writes.
Failed jobs remain visible and manually retryable. A needs_attention progress state stops automatic requests from the media usage tracking settings page until the underlying failure is resolved.
Recover Collection Deletion
GET /_emdash/api/admin/media-usage/collection-deletions?state=failed&limit=50&cursor=...
Returns a bounded page of durable collection-deletion work. The list defaults to failed work;
limit defaults to 50 and is capped at 100. Items include the immutable collection ID, slug,
phase, attempts, eligibility/lease timestamps, stable error code, and update time. Lease tokens,
raw database errors, content, media references, and exact backlog counts are never returned.
POST /_emdash/api/admin/media-usage/collection-deletions/retry
Content-Type: application/json
X-EmDash-Request: 1
{ "collectionId": "01COLLECTION..." }
Retry reopens failed, retrying, or expired-leased work without changing its phase. A live lease
returns 409 WORK_LEASE_ACTIVE; a concurrent state change returns 409 WORK_CHANGED. Both routes
require schema:manage, and bearer tokens also require the admin scope. They recover internal
index cleanup only and never delete media assets.
Repair media usage
POST /_emdash/api/admin/media-usage/repair
Content-Type: application/json
X-EmDash-Request: 1
Repairs the content media usage index for one collection or for all content collections. This is an admin/operator endpoint: session-authenticated callers need schema:manage, and bearer tokens must have the admin scope because the route is under /_emdash/api/admin.
All-content repair runs synchronously and sequentially in the current version. It can be expensive on large sites, so callers should trigger it deliberately and wait for the response.
Request Body
Repair one collection:
{
"scope": "collection",
"collection": "posts"
}
Repair all content collections:
{
"scope": "all"
}
The request body is required. Invalid slugs, unknown request keys, missing scope, and body-less requests return 400 instead of defaulting to all-content repair.
Response
The endpoint returns 200 when a repair invocation produces a structured result. Inspect data.status: failed and stale are repair-domain statuses, not transport errors.
{
"data": {
"status": "complete",
"indexedSourceCount": 12,
"failedSourceCount": 0,
"skippedSourceCount": 0,
"deletedSourceCount": 1,
"collections": [
{
"collection": "posts",
"status": "complete",
"indexedSourceCount": 12,
"failedSourceCount": 0,
"skippedSourceCount": 0,
"deletedSourceCount": 1,
"lastErrorCode": null,
"startedAt": "2026-07-07T12:00:00.000Z",
"completedAt": "2026-07-07T12:00:01.000Z"
}
]
}
}
Top-level response fields:
| Field | Type | Description |
|---|---|---|
status | complete | partial | failed | stale | Aggregate repair status |
indexedSourceCount | number | Sources indexed during repair |
failedSourceCount | number | Sources that failed during repair |
skippedSourceCount | number | Sources skipped, including stale conflicts |
deletedSourceCount | number | Stale usage rows deleted during repair |
collections | array | Per-collection repair summaries |
Collection summary fields:
| Field | Type | Description |
|---|---|---|
collection | string | Collection slug |
status | complete | partial | failed | stale | Collection repair status |
indexedSourceCount | number | Sources indexed for this collection |
failedSourceCount | number | Sources that failed for this collection |
skippedSourceCount | number | Sources skipped for this collection |
deletedSourceCount | number | Stale usage rows deleted for this collection |
lastErrorCode | string | null | Last collection repair error, when available |
startedAt | string | Repair start time |
completedAt | string | null | Completion time, or null for stale results |
Unknown collections return 200 with data.status: "failed" and a per-collection lastErrorCode such as COLLECTION_NOT_FOUND. Transport errors still use the standard error envelope, including 400, 401, 403, 413, and 500.
Get Media File
GET /_emdash/api/media/file/:key
Serves the actual file content. For local storage only.
Revision Endpoints
List Revisions
GET /_emdash/api/content/:collection/:entryId/revisions
Parameters
| Parameter | Type | Description |
|---|---|---|
limit | number | Max revisions to return (default: 50) |
Response
{
"success": true,
"data": {
"items": [
{
"id": "01HXK5MZSN...",
"collection": "posts",
"entryId": "01HXK5MZSN...",
"data": { ... },
"createdAt": "2025-01-24T12:00:00Z"
}
],
"total": 5
}
}
Get Revision
GET /_emdash/api/revisions/:revisionId
Restore Revision
POST /_emdash/api/revisions/:revisionId/restore
Restores content to this revision’s state and creates a new revision.
Schema Endpoints
List Collections
GET /_emdash/api/schema/collections
Response
{
"success": true,
"data": {
"items": [
{
"id": "01HXK5MZSN...",
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "preview"]
}
]
}
}
Get Collection
GET /_emdash/api/schema/collections/:slug
Parameters
| Parameter | Type | Description |
|---|---|---|
includeFields | boolean | Include field definitions (query) |
Create Collection
POST /_emdash/api/schema/collections
Content-Type: application/json
Request Body
{
"slug": "products",
"label": "Products",
"labelSingular": "Product",
"description": "Product catalog",
"supports": ["drafts", "revisions"]
}
Update Collection
PUT /_emdash/api/schema/collections/:slug
Content-Type: application/json
Delete Collection
DELETE /_emdash/api/schema/collections/:slug
Parameters
| Parameter | Type | Description |
|---|---|---|
force | boolean | Delete even if collection has content (query) |
List Fields
GET /_emdash/api/schema/collections/:slug/fields
Create Field
POST /_emdash/api/schema/collections/:slug/fields
Content-Type: application/json
Request Body
{
"slug": "price",
"label": "Price",
"type": "number",
"required": true,
"validation": {
"min": 0
}
}
Update Field
PUT /_emdash/api/schema/collections/:collectionSlug/fields/:fieldSlug
Content-Type: application/json
Delete Field
DELETE /_emdash/api/schema/collections/:collectionSlug/fields/:fieldSlug
Reorder Fields
POST /_emdash/api/schema/collections/:slug/fields/reorder
Content-Type: application/json
Request Body
{
"fieldSlugs": ["title", "content", "author", "publishedAt"]
}
Schema Export
Export Schema (JSON)
GET /_emdash/api/schema
Accept: application/json
Export Schema (TypeScript)
GET /_emdash/api/schema?format=typescript
Accept: text/typescript
Returns TypeScript interfaces for all collections.
Plugin Endpoints
List Plugins
GET /_emdash/api/admin/plugins
Get Plugin
GET /_emdash/api/admin/plugins/:id
Enable Plugin
POST /_emdash/api/admin/plugins/:id/enable
Disable Plugin
POST /_emdash/api/admin/plugins/:id/disable
Error Codes
| Code | HTTP Status | Description |
|---|---|---|
NOT_FOUND | 404 | Resource not found |
VALIDATION_ERROR | 400 | Invalid input data |
UNAUTHORIZED | 401 | Missing or invalid token |
FORBIDDEN | 403 | Insufficient permissions |
CONTENT_LIST_ERROR | 500 | Failed to list content |
CONTENT_CREATE_ERROR | 500 | Failed to create content |
CONTENT_UPDATE_ERROR | 500 | Failed to update content |
SAVE_REJECTED | 422 | Save rejected by a plugin hook |
CONTENT_HOOK_ERROR | 500 | Plugin hook failed during save |
CONTENT_DELETE_ERROR | 500 | Failed to delete content |
MEDIA_LIST_ERROR | 500 | Failed to list media |
MEDIA_CREATE_ERROR | 500 | Failed to create media |
SCHEMA_CREATE_ERROR | 500 | Schema operation failed |
SLUG_CONFLICT | 409 | Slug already exists |
RESERVED_SLUG | 400 | Slug is reserved |
Search Endpoints
Global Search
GET /_emdash/api/search?q=hello+world
Parameters
| Parameter | Type | Description |
|---|---|---|
q | string | Search query (required) |
collections | string | Comma-separated collection slugs |
status | string | Filter by status (default: published) |
limit | number | Max results (default: 20) |
cursor | string | Pagination cursor |
Response
{
"success": true,
"data": {
"items": [
{
"collection": "posts",
"id": "01HXK5MZSN...",
"slug": "hello-world",
"locale": "en",
"title": "Hello World",
"snippet": "...this is a <mark>hello</mark> <mark>world</mark> example...",
"score": 0.95
}
],
"nextCursor": "eyJvZmZzZXQiOjIwfQ"
}
}
Search Suggestions
GET /_emdash/api/search/suggest?q=hel&limit=5
Returns prefix-matched titles for autocomplete.
Configure Collection Search
POST /_emdash/api/search/enable
Content-Type: application/json
{
"collection": "posts",
"enabled": true,
"tokenize": "trigram",
"weights": {
"title": 10,
"content": 1
}
}
The optional tokenize field controls how SQLite FTS5 indexes the collection. Changing it on an
enabled collection rebuilds and repopulates that collection’s search index.
| Value | When to use |
|---|---|
porter unicode61 | Default. English-language content that benefits from Porter stemming, such as matching related word forms. Porter stemming is English-specific. |
unicode61 | Languages that use word separators but should not use English stemming. |
trigram | Languages with text that is not separated by spaces, including Japanese, Chinese, Thai, Khmer, Lao, and Burmese, or when substring matching is needed. Queries shorter than three Unicode characters return no matches. |
Omitting tokenize on a collection without a stored tokenizer uses porter unicode61. Disabling
search preserves the configured tokenizer for the next enable operation.
Rebuild Search Index
POST /_emdash/api/search/rebuild
Content-Type: application/json
{
"collection": "posts"
}
Rebuilds the FTS index for the specified collection using its stored tokenizer and field weights.
Search Stats
GET /_emdash/api/search/stats
Returns indexed document counts per collection.
Section Endpoints
List Sections
GET /_emdash/api/sections
GET /_emdash/api/sections?source=theme
GET /_emdash/api/sections?search=newsletter
Get Section
GET /_emdash/api/sections/:slug
Create Section
POST /_emdash/api/sections
Content-Type: application/json
{
"slug": "my-section",
"title": "My Section",
"keywords": ["keyword1"],
"content": [...]
}
Update Section
PUT /_emdash/api/sections/:slug
Delete Section
DELETE /_emdash/api/sections/:slug
Settings Endpoints
Get All Settings
GET /_emdash/api/settings
Update Settings
POST /_emdash/api/settings
Content-Type: application/json
{
"siteTitle": "My Site",
"tagline": "A great site",
"postsPerPage": 10
}
Menu Endpoints
List Menus
GET /_emdash/api/menus
Get Menu
GET /_emdash/api/menus/:name
Create Menu
POST /_emdash/api/menus
Content-Type: application/json
{
"name": "footer",
"label": "Footer Navigation"
}
Update Menu
PUT /_emdash/api/menus/:name
Delete Menu
DELETE /_emdash/api/menus/:name
Add Menu Item
POST /_emdash/api/menus/:name/items
Content-Type: application/json
{
"type": "page",
"referenceCollection": "pages",
"referenceId": "page_about",
"label": "About Us"
}
Reorder Menu Items
POST /_emdash/api/menus/:name/reorder
Content-Type: application/json
{
"items": [
{ "id": "item_1", "parentId": null, "sortOrder": 0 },
{ "id": "item_2", "parentId": null, "sortOrder": 1 },
{ "id": "item_3", "parentId": "item_2", "sortOrder": 0 }
]
}
Taxonomy Endpoints
List Taxonomy Definitions
GET /_emdash/api/taxonomies
Get Taxonomy
GET /_emdash/api/taxonomies/:name
A taxonomy has one definition per locale. locale selects which one to return.
Omit it and EmDash returns the definition for the site’s default locale, falling
back to the lowest locale code when the default locale has no definition. The
endpoint requires taxonomies:read.
Parameters
| Parameter | Type | Description |
|---|---|---|
name | string | Taxonomy name (path) |
locale | string | Locale of the definition (query) |
Response
{
"success": true,
"data": {
"taxonomy": {
"id": "01HXK5MZSN...",
"name": "genre",
"label": "Genres",
"labelSingular": "Genre",
"hierarchical": true,
"collections": ["books", "movies"],
"locale": "en",
"translationGroup": "01HXK5MZSN..."
}
}
}
collections lists only the collections that still exist. A collection that was
deleted after being added to the taxonomy is filtered out of the response but
kept in storage, so re-creating the collection restores the link.
Every locale of a taxonomy shares one translationGroup. An untranslated
taxonomy has its own id there, as above.
Create Taxonomy
POST /_emdash/api/taxonomies
Content-Type: application/json
{
"name": "genre",
"label": "Genres",
"labelSingular": "Genre",
"hierarchical": true,
"collections": ["books", "movies"]
}
Update Taxonomy
PUT /_emdash/api/taxonomies/:name
Content-Type: application/json
{
"label": "Categories",
"labelSingular": "Category",
"hierarchical": true,
"collections": ["books"]
}
Every field is optional and an omitted field keeps its stored value. Send
"labelSingular": null to clear it. Naming a collection that does not exist
returns VALIDATION_ERROR and writes nothing. The response is the updated
definition, in the same shape as Get Taxonomy. The endpoint
requires taxonomies:manage.
The request writes a single locale’s definition, selected by locale. Pass it
explicitly on a translated taxonomy: without it, the write lands on the
definition with the lowest locale code. Addressing a locale that has no
definition returns NOT_FOUND — unlike Get Taxonomy, this
endpoint never falls back to another locale’s row.
Do not send name or locale in the body. Both identify the definition being
written rather than a value to change, so the body rejects them with
VALIDATION_ERROR instead of ignoring them. A taxonomy cannot be renamed,
because its terms are keyed on name.
Delete Taxonomy
DELETE /_emdash/api/taxonomies/:name
Deletes the taxonomy in every locale: each locale’s definition, every term under that name, and every assignment of those terms to content. Content entries themselves are not deleted; they lose the term assignments.
There is no locale parameter, and EmDash does not refuse the request when the
taxonomy still has terms. The endpoint requires taxonomies:manage.
Response
{
"success": true,
"data": { "deleted": true }
}
List Taxonomy Translations
GET /_emdash/api/taxonomies/:name/translations
Lists every locale that the taxonomy definition has been translated into. Any
locale of the taxonomy returns the same list, so locale only chooses which
definition resolves the group. The endpoint requires taxonomies:read.
Response
{
"success": true,
"data": {
"translationGroup": "01HXK5MZSN...",
"translations": [
{ "id": "01HXK5MZSN...", "name": "genre", "label": "Genres", "locale": "en" },
{ "id": "01HXK6P2QT...", "name": "genre", "label": "Géneros", "locale": "es" }
]
}
}
To add a locale, post to Create Taxonomy with the same
name, the new locale, and translationOf set to a definition id from this
list.
List Terms
GET /_emdash/api/taxonomies/:name/terms
Terms come back in their manual order (see Reorder Terms). A new term is added to the end of its sibling group.
Create Term
POST /_emdash/api/taxonomies/:name/terms
Content-Type: application/json
{
"slug": "tutorials",
"label": "Tutorials",
"parentId": "term_abc",
"description": "How-to guides"
}
Update Term
PUT /_emdash/api/taxonomies/:name/terms/:slug
Delete Term
DELETE /_emdash/api/taxonomies/:name/terms/:slug
Reorder Terms
POST /_emdash/api/taxonomies/:name/reorder
Content-Type: application/json
{
"parentId": "term_abc",
"ids": ["term_news", "term_featured"]
}
Sets the order of one sibling group. parentId names the parent whose children
are being ordered; omit it (or send null) for the top level, which for a flat
taxonomy is every term. Reordering never changes a term’s parent — use
Update Term for that.
ids may be a subset of the group: the terms you list are permuted within the
positions they already occupy, and every other member keeps its place. That
matters when a locale doesn’t render the whole group, and it means a stale list
can’t bury the terms it left out. An id outside the group is rejected with
REORDER_MISMATCH, and at most 100 ids may be sent at once.
Because the terms you leave out hold their absolute positions, a one-step move
in a partial list can carry a term past siblings that list didn’t include. If
[A, B, C] is the full group and you send ["C", "A"] — because B isn’t
translated into the locale you’re working in — the result is [C, B, A]: A
and C swapped as asked, and a listing that does show B sees A move two
places rather than one.
There is no locale parameter. A term holds one position across every locale it
is translated into, so an id may be either a term id or a translation group, and
ordering a taxonomy in one locale orders it in all of them. Sites that need
different orders per locale should use separate taxonomies.
Set Entry Terms
POST /_emdash/api/content/:collection/:id/terms/:taxonomy
Content-Type: application/json
{
"termIds": ["term_news", "term_featured"]
}
Widget Area Endpoints
List Widget Areas
GET /_emdash/api/widget-areas
Get Widget Area
GET /_emdash/api/widget-areas/:name
Create Widget Area
POST /_emdash/api/widget-areas
Content-Type: application/json
{
"name": "sidebar",
"label": "Main Sidebar",
"description": "Appears on posts"
}
Delete Widget Area
DELETE /_emdash/api/widget-areas/:name
Add Widget
POST /_emdash/api/widget-areas/:name/widgets
Content-Type: application/json
{
"type": "content",
"title": "About",
"content": [...]
}
Update Widget
PUT /_emdash/api/widget-areas/:name/widgets/:id
Delete Widget
DELETE /_emdash/api/widget-areas/:name/widgets/:id
Reorder Widgets
POST /_emdash/api/widget-areas/:name/reorder
Content-Type: application/json
{
"widgetIds": ["widget_1", "widget_2", "widget_3"]
}
User Management Endpoints
List Users
GET /_emdash/api/admin/users
GET /_emdash/api/admin/users?role=40
GET /_emdash/api/admin/users?search=john
Get User
GET /_emdash/api/admin/users/:id
Update User
PUT /_emdash/api/admin/users/:id
Content-Type: application/json
{
"name": "John Doe",
"role": 40
}
Enable User
POST /_emdash/api/admin/users/:id/enable
Disable User
POST /_emdash/api/admin/users/:id/disable
Authentication Endpoints
Setup Status
GET /_emdash/api/setup/status
Returns whether setup is complete and if users exist.
Passkey Login
POST /_emdash/api/auth/passkey/options
Get WebAuthn authentication options.
POST /_emdash/api/auth/passkey/verify
Content-Type: application/json
{
"id": "credential-id",
"rawId": "...",
"response": {...},
"type": "public-key"
}
Verify passkey and create session.
Magic Link
POST /_emdash/api/auth/magic-link/send
Content-Type: application/json
{
"email": "[email protected]"
}
GET /_emdash/api/auth/magic-link/verify?token=xxx
Logout
POST /_emdash/api/auth/logout
Current User
GET /_emdash/api/auth/me
Invite User
POST /_emdash/api/auth/invite
Content-Type: application/json
{
"email": "[email protected]",
"role": 30
}
Passkey Management
GET /_emdash/api/auth/passkey
List user’s passkeys.
POST /_emdash/api/auth/passkey/register/options
POST /_emdash/api/auth/passkey/register/verify
Register new passkey.
PATCH /_emdash/api/auth/passkey/:id
Content-Type: application/json
{
"name": "MacBook Pro"
}
Rename passkey.
DELETE /_emdash/api/auth/passkey/:id
Delete passkey.
Import Endpoints
Analyze WordPress Export
POST /_emdash/api/import/wordpress/analyze
Content-Type: multipart/form-data
file: <WXR file>
Execute WordPress Import
POST /_emdash/api/import/wordpress/execute
Content-Type: application/json
{
"analysisId": "...",
"options": {
"includeMedia": true,
"includeTaxonomies": true,
"includeMenus": true
}
}
Rate Limiting
API endpoints may be rate-limited based on deployment configuration. When rate-limited, responses include:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
CORS
The API supports CORS for browser requests. Configure allowed origins in your deployment.