How to Set Up Webhooks in EmDash CMS

Worth being upfront: EmDash doesn't ship a built-in "Settings → Webhooks" configuration screen the way some SaaS platforms do. What it has instead is a plugin hook system that can react to content events, plus a network-access capability specifically documented for exactly this use case. This walks through building a real, minimal webhook plugin.
Table of Contents
- The Building Blocks
- Step 1: Scaffold the Plugin
- Step 2: Declare the Capability
- Step 3: Write the Hook
- Step 4: Let Site Operators Configure the Destination URL
- Step 5: Build and Install
- Choosing Which Events to Fire On
- Frequently Asked Questions
- Is there really no built-in webhook UI at all?
- Can a webhook plugin retry on failure?
- Will this work on Cloudflare Workers deployments?
- Is this safer than a native plugin doing the same thing?
- The Bottom Line
The Building Blocks
Two pieces make this work: a hook that fires on the event you care about, and network access to actually call an external URL. EmDash's hook reference lists the content lifecycle events available — `content:afterSave`, `content:afterPublish`, `content:afterUnpublish`, `content:afterDelete`, and others — and its capability system is explicit that this is a designed-for pattern:
network:request:unrestricted exists for user-configured URLs. A webhook plugin where the operator types in the destination URL needs to reach hosts that aren't in the manifest. Plugins that always call known APIs should use network:request plus allowedHosts.
That distinction matters: if your webhook always calls one known service (say, a specific Slack workspace webhook URL you control), use `network:request` with that host in `allowedHosts`. If you want site operators to type in their own destination URL — the more common "real" webhook use case — use `network:request:unrestricted`.
Step 1: Scaffold the Plugin
npx @emdash-cms/plugin-cli init webhook-notifierOr by hand — a sandboxed plugin is two files: a manifest and a runtime.
Step 2: Declare the Capability
// emdash-plugin.jsonc
{
"slug": "webhook-notifier",
"publisher": "did:plc:your-atmosphere-did",
"license": "MIT",
"author": { "name": "Your Name", "url": "https://example.com" },
"security": { "email": "security@example.com" },
"capabilities": ["content:read", "network:request:unrestricted"],
"allowedHosts": [],
"storage": {}
}`network:request:unrestricted` is what lets the plugin reach a destination URL the site operator configures themselves, rather than one hardcoded at build time — this is also exactly what the admin's plugin-install consent screen will show, so operators know upfront this plugin can call arbitrary URLs.
Step 3: Write the Hook
// src/plugin.ts
import type { SandboxedPlugin } from "emdash/plugin";
export default {
hooks: {
"content:afterPublish": async (event, ctx) => {
const webhookUrl = await ctx.kv.get<string>("settings:webhookUrl");
if (!webhookUrl || !ctx.http) return;
await ctx.http.fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event: "content.published",
collection: event.collection,
id: event.id,
slug: event.slug,
publishedAt: new Date().toISOString(),
}),
});
},
},
} satisfies SandboxedPlugin;The destination URL is read from the plugin's own KV store (`settings:webhookUrl`) rather than hardcoded — that's what makes it "user-configured" and consistent with why `network:request:unrestricted` is the right capability here.
Step 4: Let Site Operators Configure the Destination URL
Expose a route so the admin UI (or a Block Kit settings page) can save the webhook URL into the plugin's KV store:
routes: {
"settings/save": {
input: z.object({ webhookUrl: z.string().url() }),
handler: async (routeCtx, ctx) => {
await ctx.kv.set("settings:webhookUrl", routeCtx.input.webhookUrl);
return { success: true };
},
},
},That route is reachable at `/_emdash/api/plugins/webhook-notifier/settings/save` once the plugin is installed, and it's authenticated by default (write methods require the `plugins:manage` permission), so only an authorized admin can change where content-publish notifications get sent.
Step 5: Build and Install
emdash-plugin validate
emdash-plugin buildThen install it the same way as any sandboxed plugin — from the marketplace if published, or linked locally during development (`pnpm add file:../webhook-notifier` in your site, or a workspace link), followed by enabling it from Admin → Extensions.
Choosing Which Events to Fire On
Beyond `content:afterPublish`, the same pattern works for any hook in EmDash's content lifecycle:
- content:afterSave — fires on every save, draft or published. Noisy, but useful for a full activity log.
- content:afterPublish — fires specifically when something goes live. The most common webhook trigger.
- content:afterUnpublish — useful for notifying a downstream system that content was pulled.
- content:afterDelete — useful for cleanup workflows in an external system.
- media:afterUpload — for notifying a DAM or asset-processing pipeline.
Frequently Asked Questions
Is there really no built-in webhook UI at all?
Correct, not as of the documented feature set — outbound webhooks are a plugin pattern, not a core admin panel setting. The capability system (`network:request:unrestricted`, specifically called out for "webhook senders") confirms this is an intended, supported use case, just implemented via the plugin system rather than a dedicated settings page.
Can a webhook plugin retry on failure?
Not automatically — you're responsible for retry logic in your handler if the destination is unreliable. A simple approach is catching the fetch error and writing a failed-delivery record to the plugin's own storage for manual or scheduled retry via a `cron` hook.
Will this work on Cloudflare Workers deployments?
Yes — sandboxed plugins run the same way regardless of hosting platform, though resource limits differ by runner (the Cloudflare runner enforces CPU/subrequest/wall-clock limits per the platform's Worker Loader constraints).
Is this safer than a native plugin doing the same thing?
Yes, meaningfully — a sandboxed plugin can only reach the network through `ctx.http.fetch()`, which the sandbox bridge validates, and direct `fetch()` calls are blocked by the runner entirely. A native plugin has full runtime access and no such enforcement.
The Bottom Line
EmDash doesn't have a webhook settings panel, but it has exactly the pieces needed to build one — content lifecycle hooks plus a capability specifically documented for user-configured webhook destinations. A minimal version is genuinely about 30 lines of plugin code. See our guide to the EmDash REST API if the receiving end of your webhook needs to call back into EmDash.




Comments