How to Use the EmDash CMS API: A Developer Guide

How to Use the EmDash CMS API: A Developer Guide

EmDash's REST API is what the admin panel itself is built on — every button click in `/_emdash/admin` ultimately calls the same endpoints documented here. That means the API is genuinely complete, not a stripped-down subset. This is a practical map of it for scripts, CI pipelines, and anything else calling EmDash from outside the admin UI.

Table of Contents
  1. Authentication
  2. Response Format
  3. Content Endpoints
  4. Media Endpoints
  5. Schema Endpoints
  6. Search Endpoints
  7. Revision Endpoints
  8. Plugin Endpoints
  9. Optimistic Concurrency: The _rev Token
  10. A Practical Alternative: The CLI
  11. CORS for Browser-Based Requests
  12. Frequently Asked Questions
  13. Is there a rate limit?
  14. Can I use this API from a completely separate application, not just scripts?
  15. Does creating content via the API auto-publish it?
  16. How do I find a collection's exact field names before writing a create request?
  17. The Bottom Line

Authentication

Authorization: Bearer <token>
emdashkits.com

Generate a token from Admin → Settings → API Tokens, or programmatically. Every request needs this header — there's no unauthenticated read access to content by default, even for published items.

Response Format

Every response follows the same envelope, success or failure:

{ "success": true, "data": { /* ... */ } }
emdashkits.com
{
  "success": false,
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable message",
    "details": { }
  }
}
emdashkits.com

Common error codes worth checking for explicitly in scripted usage:

  • UNAUTHORIZED (401) — missing or invalid token.
  • FORBIDDEN (403) — token valid, but insufficient role/scope for this action.
  • VALIDATION_ERROR (400) — malformed input data.
  • SLUG_CONFLICT (409) — the slug you're creating already exists.
  • RESERVED_SLUG (400) — you tried to use a reserved field or content slug.
  • NOT_FOUND (404) — the resource doesn't exist.
Read also:

Content Endpoints

GET    /_emdash/api/content/:collection            # list, paginated
GET    /_emdash/api/content/:collection/:id        # get one
POST   /_emdash/api/content/:collection             # create
PUT    /_emdash/api/content/:collection/:id         # update
DELETE /_emdash/api/content/:collection/:id         # delete (soft)
emdashkits.com

List accepts `cursor`, `limit` (default 50), `status`, `orderBy`, and `order` as query parameters. A create request:

POST /_emdash/api/content/posts
Content-Type: application/json

{
  "data": { "title": "New Post", "content": [ /* Portable Text blocks */ ] },
  "slug": "new-post",
  "status": "draft"
}
emdashkits.com

Media Endpoints

GET    /_emdash/api/media               # list, filter by mimeType
GET    /_emdash/api/media/:id           # get one
POST   /_emdash/api/media               # register metadata after upload
PUT    /_emdash/api/media/:id           # update alt text, caption, dimensions
DELETE /_emdash/api/media/:id           # delete
GET    /_emdash/api/media/file/:key     # serve the actual file (local storage only)
emdashkits.com
This endpoint records media metadata after upload. Use the upload endpoint or signed URLs to upload the actual file.

That's the one non-obvious part of the media API: `POST /_emdash/api/media` registers a metadata record for a file that's already been placed in storage — it doesn't accept a binary upload body itself. The actual bytes go through a signed upload URL flow (or the admin UI's drag-and-drop, which handles this for you).

Schema Endpoints

GET /_emdash/api/schema/collections
emdashkits.com

Lists every collection and its field definitions — useful for a script that needs to introspect a site's content model before creating entries, rather than hardcoding field names it might get wrong.

Search Endpoints

GET  /_emdash/api/search?q=hello+world              # global search across collections
GET  /_emdash/api/search/suggest?q=hel&limit=5      # autocomplete
POST /_emdash/api/search/rebuild                     # rebuild the FTS index
GET  /_emdash/api/search/stats                       # indexed document counts
emdashkits.com

Global search accepts `collections` (comma-separated slugs to scope the search), `status` (defaults to `published`), `limit`, and `cursor`. Results include a highlighted `snippet` with `<mark>` tags around matched terms — ready to render directly if you strip or style that tag appropriately.

Revision Endpoints

GET  /_emdash/api/content/:collection/:entryId/revisions
GET  /_emdash/api/revisions/:revisionId
POST /_emdash/api/revisions/:revisionId/restore
emdashkits.com

Restoring a revision doesn't just roll back — it creates a new revision from the restored state, preserving the full history rather than deleting anything after the restore point.

Plugin Endpoints

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
emdashkits.com

Any plugin you install can also expose its own routes under `/_emdash/api/plugins/<slug>/<route-name>` — those aren't documented centrally since they're plugin-specific, but they follow the same auth and response-envelope conventions as core endpoints.

Optimistic Concurrency: The _rev Token

Content responses include a `_rev` token. Pass it back on update to prove you've seen the current state before overwriting it — this is what prevents two concurrent editors (or a script and a human) from silently clobbering each other's changes:

# 1. Read, note the _rev
curl .../content/posts/01ABC123 -H "Authorization: Bearer $TOKEN"

# 2. Update, passing that _rev back
curl -X PUT .../content/posts/01ABC123 \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"data": {"title": "Updated"}, "_rev": "MToyMDI2LTAyLTE0..."}'
emdashkits.com

If the item changed since your read, the server returns a 409 Conflict instead of silently overwriting — re-read and retry rather than assuming your write went through.

A Practical Alternative: The CLI

For scripting and CI, the `emdash` CLI wraps this same API with a friendlier interface — `npx emdash content create posts --file post.json`, `npx emdash content publish posts 01ABC123`, and so on — and handles the `_rev` conflict-detection dance for you on updates. Reach for the raw REST API when you need something the CLI doesn't cover, or when calling from a non-Node environment.

CORS for Browser-Based Requests

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

Relevant specifically if you're calling the API directly from client-side JavaScript in a separate application — server-to-server calls (the more common pattern) aren't subject to CORS at all.

Frequently Asked Questions

Is there a rate limit?

API endpoints may be rate-limited depending on your deployment configuration. A rate-limited response returns 429 with a `Retry-After` header telling you how long to wait before retrying.

Can I use this API from a completely separate application, not just scripts?

Technically yes, with CORS configured — but see our note on what that trade-off actually costs you compared to EmDash's native Astro integration before building a whole separate frontend around it.

Does creating content via the API auto-publish it?

Depends on which tool: the REST API's create endpoint respects whatever `status` you pass (defaulting to what you specify, typically `draft`); the CLI's `content create` auto-publishes unless you pass `--draft` explicitly — check which one you're using.

How do I find a collection's exact field names before writing a create request?

`GET /_emdash/api/schema/collections`, or `npx emdash schema get <collection>` via the CLI — both return the live field list so you're not guessing or working from stale documentation.

The Bottom Line

EmDash's REST API is comprehensive and consistent — one response envelope, one auth scheme, and the same endpoints the admin panel itself calls. For most scripting needs, the CLI is the faster path; reach for raw REST when you need CORS-based browser access or something outside the CLI's coverage. See our guide to customizing content models to understand what you're querying and creating against.

Share

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

TL;DR — A custom session-cookie login flow appeared to succeed on localhost (the OTP verified, the response looked fine) but every subsequent request to a login-gated page treated the visitor as logged out. Identical code worked fine on the live HTTPS site. The cookie's Secure attribute was hardcoded to true — and per the cookie spec, browsers never store or send a Secure cookie over a plain, non-HTTPS connection.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Fix
  4. Lessons learned

Diagnostic

Check the actual Set-Cookie response header and the browser's own cookie storage panel — on localhost over http://, the cookie is sent by the server but never actually stored by the browser.

Root cause

// before -- assumes the app is always served over HTTPS
setCookie("session", token, { secure: true, httpOnly: true });
emdashkits.com

A cookie config that quietly assumes "we're always on HTTPS" breaks the instant you test over plain HTTP, which local dev servers commonly are.

Read also:

Fix

// after -- derive secure from the actual request protocol
const isHttps = request.url.startsWith("https://");
setCookie("session", token, { secure: isHttps, httpOnly: true });
emdashkits.com

Lessons learned

  • Any Secure-flagged cookie needs to key off the real request scheme, not an assumption baked in once at cookie-creation time.
  • "Works in production, silently fails in local dev" is a strong signal to check cookie flags before anything else in an auth flow.
  • Check other cookies in the same codebase for the same hardcoded assumption — if one cookie has this bug, sibling cookies set the same way are worth auditing too.
Share
Previous Article

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Comments

Write a comment

Related Articles

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

TL;DR — GA4's gtag.js snippet was installed correctly, the page loaded with no visible JavaScript error, and GA4's real-time report still showed zero activity. The CMS's default Content-Security-Policy had no allowance for analytics domains and no config option to add one — so every request to Google's tracking endpoints was blocked at the browser level before it could fail loudly.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Lessons learned

Diagnostic

Check the browser's dedicated CSP violation reporting, not the regular console error list — CSP blocks are reported through their own channel, not thrown as normal script errors, so "no console errors" doesn't mean nothing was blocked.

Root cause

The CSP's script-src and connect-src directives had no entry for googletagmanager.com or google-analytics.com, and the CMS exposed no configuration surface to add one — the only way in was patching the CSP directives directly.

// patch-package: add analytics domains to the existing CSP directives
scriptSrc.push("https://www.googletagmanager.com");
connectSrc.push("https://www.google-analytics.com", "https://www.googletagmanager.com");
emdashkits.com
Read also:

Lessons learned

  • "No console errors" is not proof nothing was blocked — CSP violations live in their own reporting surface and are easy to miss if you're only scanning for red error text.
  • Before adding any third-party script tag to a site with a CSP already in place, check the CSP's directives first rather than assuming a silently-empty analytics dashboard means a snippet-installation mistake.
Share
Previous Article

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Next Article

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)