API Routes

On this page

Plugins can expose API routes for their admin UI and external integrations. Routes are mounted under /_emdash/api/plugins/<slug>/<route-name> (the <slug> is the plugin’s slug field from emdash-plugin.jsonc — exposed at runtime as ctx.plugin.id) and run inside the sandbox runtime with the same PluginContext that hooks receive.

This page covers sandboxed plugins. The API surface for native plugins is the same; the only difference is the handler signature — see the note in Native plugins for details.

Defining routes

Declare routes in the default export of src/plugin.ts:

import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "astro/zod";

export default {
	routes: {
		status: {
			handler: async (_routeCtx, ctx) => {
				return { ok: true, plugin: ctx.plugin.id };
			},
		},

		submissions: {
			input: z.object({
				formId: z.string().optional(),
				limit: z.number().default(50),
				cursor: z.string().optional(),
			}),
			handler: async (routeCtx, ctx) => {
				const { formId, limit, cursor } = routeCtx.input;

				const result = await ctx.storage.submissions.query({
					where: formId ? { formId } : undefined,
					orderBy: { createdAt: "desc" },
					limit,
					cursor,
				});

				return result;
			},
		},
	},
} satisfies SandboxedPlugin;

satisfies SandboxedPlugin infers routeCtx and ctx — no parameter annotations needed. Sandboxed route handlers take two arguments: (routeCtx, ctx).

  • routeCtx carries request-shaped data: { input, request, requestMeta }.
  • ctx is the same PluginContext you get inside hooks — ctx.storage, ctx.kv, ctx.content, ctx.http, ctx.log, etc.

Route URLs

Routes mount at /_emdash/api/plugins/<slug>/<route-name>. Route names can include slashes for nested paths.

Plugin idRoute nameURL
formsstatus/_emdash/api/plugins/forms/status
formssubmissions/_emdash/api/plugins/forms/submissions
seosettings/save/_emdash/api/plugins/seo/settings/save
analyticsevents/recent/_emdash/api/plugins/analytics/events/recent

Authentication and CSRF

Plugin routes are authenticated by default. The dispatcher requires a session (or a token with the admin scope) before it’ll call your handler. Private routes default to the plugins:manage permission for backwards compatibility. Set permission to a narrower EmDash RBAC permission when the operation belongs to an existing content, media, schema, or settings capability:

routes: {
	create: {
		permission: "content:create",
		input: z.object({ title: z.string() }),
		handler: async (routeCtx, ctx) => {
			// ...
		},
	},
},

Private routes require the X-EmDash-Request: 1 CSRF header for cookie-authenticated requests. The admin UI sends it automatically; token-authenticated requests are exempt.

To opt a route out of auth and CSRF, mark it public: true:

routes: {
	track: {
		public: true,
		input: z.object({ event: z.string() }),
		handler: async (routeCtx, ctx) => {
			ctx.log.info("Tracked", { event: routeCtx.input.event });
			return { ok: true };
		},
	},
},

Caching public responses

API responses default to Cache-Control: private, no-store. For public routes that serve the same data to everyone — a product catalog, a public search index — that means every page view pays a full round-trip to the origin. Public routes can opt in to CDN/browser caching with cacheControl:

routes: {
	catalog: {
		public: true,
		cacheControl: "public, max-age=60, stale-while-revalidate=300",
		handler: async (ctx) => listProducts(ctx),
	},
},

The header is applied only to successful GET responses of public routes. Errors are never cached, other methods keep the default, and setting cacheControl on a private route has no effect — authenticated responses always stay private, no-store.

Exposing a route as an MCP tool

Plugins may explicitly expose selected private routes through EmDash’s MCP server. MCP exposure is never inferred from the route list:

const createEventInput = z.object({
	title: z.string().min(1),
	startsAt: z.string().datetime(),
});

export default {
	routes: {
		"events/create": {
			permission: "content:create",
			input: createEventInput,
			handler: async (routeCtx, ctx) => {
				return { id: await createEvent(routeCtx.input, ctx) };
			},
		},
	},
	mcp: {
		tools: {
			createEvent: {
				description: "Create a calendar event when the user asks to add one.",
				route: "events/create",
				input: createEventInput,
				output: z.object({ id: z.string() }),
				destructive: false,
			},
		},
	},
} satisfies SandboxedPlugin;

EmDash exposes this as <pluginId>__createEvent. The referenced route must be private and declare permission. Input schemas are required; output schemas are optional. Set destructive: true for tools that delete, overwrite, publish, charge, or otherwise perform a difficult-to-reverse action.

An administrator must separately enable a plugin’s MCP tools after reviewing their names, descriptions, routes, permissions, and destructive flags. Calling the tool then requires both the route permission and either the mcp:tools token scope or mcp:tools:<pluginId>.

Input validation

input accepts a Zod schema. The dispatcher parses the request body (POST/PUT/PATCH) or query string (GET/DELETE), validates it, and passes the typed result to your handler as routeCtx.input. Invalid input returns a 400 before your handler runs.

routes: {
	create: {
		input: z.object({
			title: z.string().min(1).max(200),
			email: z.string().email(),
			priority: z.enum(["low", "medium", "high"]).default("medium"),
			tags: z.array(z.string()).optional(),
		}),
		handler: async (routeCtx, ctx) => {
			const { title, email, priority, tags } = routeCtx.input;

			await ctx.storage.items.put(`item_${Date.now()}`, {
				title,
				email,
				priority,
				tags: tags ?? [],
				createdAt: new Date().toISOString(),
			});

			return { success: true };
		},
	},
},

Return values

Return any JSON-serialisable value. The dispatcher wraps it in EmDash’s standard envelope ({ success: true, data: <your value> }) and serves it as application/json.

return { id: "abc", count: 42 };  // wrapped to { success: true, data: { id, count } }
return [1, 2, 3];                 // wrapped to { success: true, data: [1, 2, 3] }

Errors

Throw to return an error response. Anything that isn’t a known plugin error returns a generic message — internal exceptions are masked rather than leaking stack traces or database errors:

handler: async (routeCtx, ctx) => {
	const item = await ctx.storage.items.get(routeCtx.input.id);
	if (!item) {
		throw new Error("Item not found");
	}
	return item;
},

For a specific status code, throw a Response:

handler: async (routeCtx, ctx) => {
	const item = await ctx.storage.items.get(routeCtx.input.id);
	if (!item) {
		throw new Response(JSON.stringify({ error: "Not found" }), {
			status: 404,
			headers: { "Content-Type": "application/json" },
		});
	}
	return item;
},

HTTP methods

Routes respond to all methods. Branch on routeCtx.request.method if you need per-method behaviour:

routes: {
	item: {
		input: z.object({ id: z.string() }),
		handler: async (routeCtx, ctx) => {
			const { id } = routeCtx.input;

			switch (routeCtx.request.method) {
				case "GET":
					return await ctx.storage.items.get(id);
				case "DELETE":
					await ctx.storage.items.delete(id);
					return { deleted: true };
				default:
					throw new Response("Method not allowed", { status: 405 });
			}
		},
	},
},

Accessing the request

routeCtx.request is a SandboxedRequest: a portable { url, method, headers } record that behaves identically in-process and inside an isolate. headers is a Record<string, string> keyed by lowercased header name — index it by the lowercased name, or iterate with Object.entries. url is a string, so new URL(request.url) parses query params. routeCtx.requestMeta carries IP, user agent, and geo data normalised across platforms when available.

handler: async (routeCtx, ctx) => {
	const { request, requestMeta } = routeCtx;

	const auth = request.headers["authorization"]; // lowercased key, no .get()
	const url = new URL(request.url);
	const page = url.searchParams.get("page");

	ctx.log.info("Request", { meta: requestMeta });

	if (request.method !== "POST") {
		throw new Response("POST required", { status: 405 });
	}
},

Common patterns

Settings via KV

Sandboxed plugins read and write settings through the KV store, conventionally under a settings: prefix. The auto-generated settingsSchema form is native-only — for sandboxed plugins, expose the read/write through routes and render the form in Block Kit.

routes: {
	settings: {
		handler: async (_routeCtx, ctx) => {
			const settings = await ctx.kv.list("settings:");
			const result: Record<string, unknown> = {};
			for (const entry of settings) {
				result[entry.key.replace("settings:", "")] = entry.value;
			}
			return result;
		},
	},

	"settings/save": {
		input: z.object({
			enabled: z.boolean().optional(),
			apiKey: z.string().optional(),
			maxItems: z.number().optional(),
		}),
		handler: async (routeCtx, ctx) => {
			for (const [key, value] of Object.entries(routeCtx.input)) {
				if (value !== undefined) {
					await ctx.kv.set(`settings:${key}`, value);
				}
			}
			return { success: true };
		},
	},
},

Paginated list

Return cursor-based pagination from a storage query — the response shape matches what the rest of EmDash uses:

routes: {
	list: {
		input: z.object({
			limit: z.number().min(1).max(100).default(50),
			cursor: z.string().optional(),
			status: z.string().optional(),
		}),
		handler: async (routeCtx, ctx) => {
			const { limit, cursor, status } = routeCtx.input;

			const result = await ctx.storage.items.query({
				where: status ? { status } : undefined,
				orderBy: { createdAt: "desc" },
				limit,
				cursor,
			});

			return {
				items: result.items.map((item) => ({ id: item.id, ...item.data })),
				cursor: result.cursor,
				hasMore: result.hasMore,
			};
		},
	},
},

External API proxy

Proxy a request to an external service through ctx.http (requires network:request capability and an entry in allowedHosts):

routes: {
	forecast: {
		input: z.object({ city: z.string() }),
		handler: async (routeCtx, ctx) => {
			if (!ctx.http) throw new Error("Network capability not granted");

			const apiKey = await ctx.kv.get<string>("settings:apiKey");
			if (!apiKey) throw new Error("API key not configured");

			const response = await ctx.http.fetch(
				`https://api.weather.example.com/forecast?city=${routeCtx.input.city}`,
				{ headers: { "X-API-Key": apiKey } },
			);

			if (!response.ok) {
				throw new Error(`Weather API error: ${response.status}`);
			}
			return response.json();
		},
	},
},

Calling routes from the admin UI

Use usePluginAPI() from the admin package — it adds the X-EmDash-Request CSRF header and the plugin id prefix automatically:

import { usePluginAPI } from "@emdash-cms/admin";

function SettingsPage() {
	const api = usePluginAPI();

	const handleSave = async (settings) => {
		await api.post("settings/save", settings);
	};

	const loadSettings = async () => {
		return api.get("settings");
	};
}

Calling routes from queue and scheduled handlers

Platform-event handlers (a Cloudflare Queue consumer, a custom scheduled() handler) have no HTTP request and therefore no locals.emdash. Use withEmDashRuntime() from emdash/middleware to get the runtime directly and invoke a plugin route without a request:

import { withEmDashRuntime } from "emdash/middleware";

export default {
	// ... fetch/scheduled from @emdash-cms/cloudflare/worker

	async queue(batch: MessageBatch) {
		await withEmDashRuntime(async (runtime) => {
			for (const message of batch.messages) {
				const result = await runtime.handlePluginApiRoute(
					"my-plugin",
					"POST",
					"/finishJob",
					new Request("https://internal/", {
						method: "POST",
						body: JSON.stringify(message.body),
					}),
				);
				if (result.success) message.ack();
				else message.retry();
			}
		});
	},
};

This resolves the same cached runtime that request handlers use, so plugin storage, hooks, and media access all behave exactly as they do during a request. On connection-backed database adapters (e.g. Postgres over Hyperdrive) the callback runs under an event-scoped connection that is committed and closed when it returns.

Calling routes externally

Public routes are callable directly:

curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
  -H "Content-Type: application/json" \
  -d '{"event": "pageview"}'

Private routes need session credentials or an API token with the admin scope:

curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"title": "Hello", "email": "user@example.com"}'

Route context reference

// What sandboxed route handlers receive as their two arguments

interface SandboxedRequest {
	url: string;
	method: string;
	headers: Record<string, string>; // lowercased keys
}

interface SandboxedRouteContext {
	input: unknown; // narrow with the `input` Zod schema at the route level
	request: SandboxedRequest;
	requestMeta?: unknown;
}

interface PluginContext {
	plugin: { id: string; version: string };
	storage: PluginStorage;
	kv: KVAccess;
	log: LogAccess;
	site: SiteInfo;
	url(path: string): string;
	cron?: CronAccess;
	content?: ContentAccess;       // when content:read or content:write declared
	taxonomies?: TaxonomyAccess;   // when taxonomies:read declared
	media?: MediaAccess;           // when media:read or media:write declared
	http?: HttpAccess;             // when network:request declared
	users?: UserAccess;            // when users:read declared
	email?: EmailAccess;           // when email:send declared and provider configured
}

Native plugins receive a single RouteContext argument that combines the two — see Creating native plugins if you’re going that route.