Skip to content

REST API Reference

EmDash exposes a REST API at /_emdash/api/ for content management, media uploads, and schema operations.

API requests require authentication via a Bearer token in the Authorization header:

Authorization: Bearer <token>

Generate tokens through the admin interface or programmatically.

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": { ... }
}
}
GET /_emdash/api/content/:collection
ParameterTypeDescription
collectionstringCollection slug (path)
cursorstringPagination cursor (query)
limitnumberItems per page (query, default: 50)
statusstringFilter by status (query)
orderBystringField to sort by (query)
orderstringSort direction: asc or desc (query)
{
"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 /_emdash/api/content/:collection/:id
{
"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"
}
}
}
POST /_emdash/api/content/:collection
Content-Type: application/json
{
"data": {
"title": "New Post",
"content": [...]
},
"slug": "new-post",
"status": "draft"
}
{
"success": true,
"data": {
"item": { ... }
}
}
PUT /_emdash/api/content/:collection/:id
Content-Type: application/json
{
"data": {
"title": "Updated Title"
},
"status": "published"
}
DELETE /_emdash/api/content/:collection/:id
{
"success": true,
"data": {
"success": true
}
}
GET /_emdash/api/media?includeUsage=1
ParameterTypeDescription
cursorstringOpaque pagination cursor
limitnumberItems per page, from 1 to 100 (default: 50)
mimeTypestringFilter by one or more comma-separated MIME types
qstringCase-insensitive filename search
includeUsage1Include a coverage-aware usage summary on every returned item
{
"success": true,
"data": {
"items": [
{
"id": "01HXK5MZSN...",
"filename": "photo.jpg",
"mimeType": "image/jpeg",
"size": 102400,
"width": 1920,
"height": 1080,
"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 /_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.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:

StatusMeaning
completeEvery registered collection has current, completed usage coverage
neverNo registered collection has completed an initial usage repair
runningA usage repair is currently running
partialCoverage is mixed or only part of the registered scope was indexed
failedCoverage failed across the registered scope
staleIndexed coverage is outdated
unknownStored coverage contains a state this version does not recognize

Only complete supports a scoped complete-zero statement within the EmDash-managed 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 /_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 managed by EmDash content collections. It does not scan custom code, rendered HTML, settings, menus, widgets, plugin-private data, external sites, or provider-only assets.

POST /_emdash/api/media
Content-Type: application/json
{
"filename": "photo.jpg",
"mimeType": "image/jpeg",
"size": 102400,
"width": 1920,
"height": 1080,
"storageKey": "uploads/photo.jpg"
}
PUT /_emdash/api/media/:id
Content-Type: application/json
{
"alt": "Photo description",
"caption": "Photo caption"
}
DELETE /_emdash/api/media/:id
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.

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. When scheduled maintenance is disabled, failed jobs remain visible and manually retryable, but no automatic freshness deadline is promised.

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.

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.

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.

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:

FieldTypeDescription
statuscomplete | partial | failed | staleAggregate repair status
indexedSourceCountnumberSources indexed during repair
failedSourceCountnumberSources that failed during repair
skippedSourceCountnumberSources skipped, including stale conflicts
deletedSourceCountnumberStale usage rows deleted during repair
collectionsarrayPer-collection repair summaries

Collection summary fields:

FieldTypeDescription
collectionstringCollection slug
statuscomplete | partial | failed | staleCollection repair status
indexedSourceCountnumberSources indexed for this collection
failedSourceCountnumberSources that failed for this collection
skippedSourceCountnumberSources skipped for this collection
deletedSourceCountnumberStale usage rows deleted for this collection
lastErrorCodestring | nullLast collection repair error, when available
startedAtstringRepair start time
completedAtstring | nullCompletion 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 /_emdash/api/media/file/:key

Serves the actual file content. For local storage only.

GET /_emdash/api/content/:collection/:entryId/revisions
ParameterTypeDescription
limitnumberMax revisions to return (default: 50)
{
"success": true,
"data": {
"items": [
{
"id": "01HXK5MZSN...",
"collection": "posts",
"entryId": "01HXK5MZSN...",
"data": { ... },
"createdAt": "2025-01-24T12:00:00Z"
}
],
"total": 5
}
}
GET /_emdash/api/revisions/:revisionId
POST /_emdash/api/revisions/:revisionId/restore

Restores content to this revision’s state and creates a new revision.

GET /_emdash/api/schema/collections
{
"success": true,
"data": {
"items": [
{
"id": "01HXK5MZSN...",
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "preview"]
}
]
}
}
GET /_emdash/api/schema/collections/:slug
ParameterTypeDescription
includeFieldsbooleanInclude field definitions (query)
POST /_emdash/api/schema/collections
Content-Type: application/json
{
"slug": "products",
"label": "Products",
"labelSingular": "Product",
"description": "Product catalog",
"supports": ["drafts", "revisions"]
}
PUT /_emdash/api/schema/collections/:slug
Content-Type: application/json
DELETE /_emdash/api/schema/collections/:slug
ParameterTypeDescription
forcebooleanDelete even if collection has content (query)
GET /_emdash/api/schema/collections/:slug/fields
POST /_emdash/api/schema/collections/:slug/fields
Content-Type: application/json
{
"slug": "price",
"label": "Price",
"type": "number",
"required": true,
"validation": {
"min": 0
}
}
PUT /_emdash/api/schema/collections/:collectionSlug/fields/:fieldSlug
Content-Type: application/json
DELETE /_emdash/api/schema/collections/:collectionSlug/fields/:fieldSlug
POST /_emdash/api/schema/collections/:slug/fields/reorder
Content-Type: application/json
{
"fieldSlugs": ["title", "content", "author", "publishedAt"]
}
GET /_emdash/api/schema
Accept: application/json
GET /_emdash/api/schema?format=typescript
Accept: text/typescript

Returns TypeScript interfaces for all collections.

GET /_emdash/api/admin/plugins
GET /_emdash/api/admin/plugins/:id
POST /_emdash/api/admin/plugins/:id/enable
POST /_emdash/api/admin/plugins/:id/disable
CodeHTTP StatusDescription
NOT_FOUND404Resource not found
VALIDATION_ERROR400Invalid input data
UNAUTHORIZED401Missing or invalid token
FORBIDDEN403Insufficient permissions
CONTENT_LIST_ERROR500Failed to list content
CONTENT_CREATE_ERROR500Failed to create content
CONTENT_UPDATE_ERROR500Failed to update content
CONTENT_DELETE_ERROR500Failed to delete content
MEDIA_LIST_ERROR500Failed to list media
MEDIA_CREATE_ERROR500Failed to create media
SCHEMA_CREATE_ERROR500Schema operation failed
SLUG_CONFLICT409Slug already exists
RESERVED_SLUG400Slug is reserved
GET /_emdash/api/search?q=hello+world
ParameterTypeDescription
qstringSearch query (required)
collectionsstringComma-separated collection slugs
statusstringFilter by status (default: published)
limitnumberMax results (default: 20)
cursorstringPagination cursor
{
"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"
}
}
GET /_emdash/api/search/suggest?q=hel&limit=5

Returns prefix-matched titles for autocomplete.

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.

ValueWhen to use
porter unicode61Default. English-language content that benefits from Porter stemming, such as matching related word forms. Porter stemming is English-specific.
unicode61Languages that use word separators but should not use English stemming.
trigramLanguages 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.

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.

GET /_emdash/api/search/stats

Returns indexed document counts per collection.

GET /_emdash/api/sections
GET /_emdash/api/sections?source=theme
GET /_emdash/api/sections?search=newsletter
GET /_emdash/api/sections/:slug
POST /_emdash/api/sections
Content-Type: application/json
{
"slug": "my-section",
"title": "My Section",
"keywords": ["keyword1"],
"content": [...]
}
PUT /_emdash/api/sections/:slug
DELETE /_emdash/api/sections/:slug
GET /_emdash/api/settings
POST /_emdash/api/settings
Content-Type: application/json
{
"siteTitle": "My Site",
"tagline": "A great site",
"postsPerPage": 10
}
GET /_emdash/api/menus
GET /_emdash/api/menus/:name
POST /_emdash/api/menus
Content-Type: application/json
{
"name": "footer",
"label": "Footer Navigation"
}
PUT /_emdash/api/menus/:name
DELETE /_emdash/api/menus/:name
POST /_emdash/api/menus/:name/items
Content-Type: application/json
{
"type": "page",
"referenceCollection": "pages",
"referenceId": "page_about",
"label": "About Us"
}
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 }
]
}
GET /_emdash/api/taxonomies
POST /_emdash/api/taxonomies
Content-Type: application/json
{
"name": "genre",
"label": "Genres",
"labelSingular": "Genre",
"hierarchical": true,
"collections": ["books", "movies"]
}
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.

POST /_emdash/api/taxonomies/:name/terms
Content-Type: application/json
{
"slug": "tutorials",
"label": "Tutorials",
"parentId": "term_abc",
"description": "How-to guides"
}
PUT /_emdash/api/taxonomies/:name/terms/:slug
DELETE /_emdash/api/taxonomies/:name/terms/:slug
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.

POST /_emdash/api/content/:collection/:id/terms/:taxonomy
Content-Type: application/json
{
"termIds": ["term_news", "term_featured"]
}
GET /_emdash/api/widget-areas
GET /_emdash/api/widget-areas/:name
POST /_emdash/api/widget-areas
Content-Type: application/json
{
"name": "sidebar",
"label": "Main Sidebar",
"description": "Appears on posts"
}
DELETE /_emdash/api/widget-areas/:name
POST /_emdash/api/widget-areas/:name/widgets
Content-Type: application/json
{
"type": "content",
"title": "About",
"content": [...]
}
PUT /_emdash/api/widget-areas/:name/widgets/:id
DELETE /_emdash/api/widget-areas/:name/widgets/:id
POST /_emdash/api/widget-areas/:name/reorder
Content-Type: application/json
{
"widgetIds": ["widget_1", "widget_2", "widget_3"]
}
GET /_emdash/api/admin/users
GET /_emdash/api/admin/users?role=40
GET /_emdash/api/admin/users?search=john
GET /_emdash/api/admin/users/:id
PUT /_emdash/api/admin/users/:id
Content-Type: application/json
{
"name": "John Doe",
"role": 40
}
POST /_emdash/api/admin/users/:id/enable
POST /_emdash/api/admin/users/:id/disable
GET /_emdash/api/setup/status

Returns whether setup is complete and if users exist.

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.

POST /_emdash/api/auth/magic-link/send
Content-Type: application/json
{
"email": "user@example.com"
}
GET /_emdash/api/auth/magic-link/verify?token=xxx
POST /_emdash/api/auth/logout
GET /_emdash/api/auth/me
POST /_emdash/api/auth/invite
Content-Type: application/json
{
"email": "newuser@example.com",
"role": 30
}
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.

POST /_emdash/api/import/wordpress/analyze
Content-Type: multipart/form-data
file: <WXR file>
POST /_emdash/api/import/wordpress/execute
Content-Type: application/json
{
"analysisId": "...",
"options": {
"includeMedia": true,
"includeTaxonomies": true,
"includeMenus": true
}
}

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

The API supports CORS for browser requests. Configure allowed origins in your deployment.