REST API リファレンス

このページ

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

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)

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

ParameterTypeDescription
cursorstringOpaque pagination cursor
pagenumberNumbered page, starting at 1; cannot be combined with cursor
limitnumberItems per page, from 1 to 100 (default: 50)
mimeTypestringFilter by one or more comma-separated MIME types
qstringCase-insensitive filename search
folderIdstringFolder ID, or unfiled for the Main library
includeUsage1Include 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:

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 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:

FieldDescription
widthImage width in pixels
heightImage height in pixels
fieldIdField whose configured MIME type allowlist applies to the upload
thumbnailDownscaled 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.

  1. 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, and size are required. The request also accepts the following optional fields:

    FieldDescription
    contentHashsha1: plus 40 lowercase hexadecimal characters, used to find a match
    fieldIdField whose configured MIME type allowlist applies to the upload

    The response contains the URL, method, and headers for the file upload. uploadUrl is 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 contentHash matches an existing media item with the same MIME type and size, the response contains existing: true, mediaId, storageKey, and url instead of an upload target. Use the returned media item and stop. Do not upload or confirm the file.

  2. Upload the file to uploadUrl

    Start with the returned method and headers. Resolve a relative uploadUrl against 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
    	}
    }
  3. Confirm the upload

    Confirming checks the stored file and changes the media item from pending to ready. size, width, and height are 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

StatusCodeCause
400NO_FILEThe multipart request has no file field, or the upload body is missing
400INVALID_TYPEThe MIME type is not allowed or does not match the pending media item
400VALIDATION_ERRORUpload metadata is missing, invalid, or exceeds the configured size limit
400FILE_NOT_FOUNDConfirmation cannot find the uploaded object
400UPLOAD_SIZE_MISMATCHThe declared, uploaded, and confirmed sizes do not match
400INVALID_STATEThe media item is not pending
404NOT_FOUNDThe media item does not exist
409INVALID_STATEThe pending media item changed during confirmation
413PAYLOAD_TOO_LARGEThe 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=...
ParameterTypeDescription
cursorstringOpaque pagination cursor
limitnumberFolders per page, from 1 to 100 (default: 50)
qstringCase-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

StatusCodeCause
400INVALID_CURSORThe folder-list cursor is invalid
400VALIDATION_ERRORA folder name, folder ID, or list parameter is invalid
404NOT_FOUNDA folder or folder assignment target does not exist
409CONFLICTA 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

  1. Stop all direct database writers. Wait for writes already in progress to finish. EmDash fences its own writes during setup.
  2. Call the activation GET endpoint to check the current state.
  3. Call the activation POST endpoint once with writersDrained: true.
  4. Call the progress POST endpoint serially, following nextRequestInMs, until activation becomes active.
  5. Resume direct database writes after activation becomes active.
  6. Continue progress requests until historical indexing reports ready and nextRequestInMs is null.

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:

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 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

ParameterTypeDescription
limitnumberMax 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

ParameterTypeDescription
includeFieldsbooleanInclude 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

ParameterTypeDescription
forcebooleanDelete 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

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
SAVE_REJECTED422Save rejected by a plugin hook
CONTENT_HOOK_ERROR500Plugin hook failed during save
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

Search Endpoints

GET /_emdash/api/search?q=hello+world

Parameters

ParameterTypeDescription
qstringSearch query (required)
collectionsstringComma-separated collection slugs
statusstringFilter by status (default: published)
limitnumberMax results (default: 20)
cursorstringPagination 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.

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.

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
}

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

ParameterTypeDescription
namestringTaxonomy name (path)
localestringLocale 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.

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.