How to Set Up Webhooks in EmDash CMS

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
  1. The Building Blocks
  2. Step 1: Scaffold the Plugin
  3. Step 2: Declare the Capability
  4. Step 3: Write the Hook
  5. Step 4: Let Site Operators Configure the Destination URL
  6. Step 5: Build and Install
  7. Choosing Which Events to Fire On
  8. Frequently Asked Questions
  9. Is there really no built-in webhook UI at all?
  10. Can a webhook plugin retry on failure?
  11. Will this work on Cloudflare Workers deployments?
  12. Is this safer than a native plugin doing the same thing?
  13. 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-notifier
emdashkits.com

Or by hand — a sandboxed plugin is two files: a manifest and a runtime.

Read also:

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": {}
}
emdashkits.com

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

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 };
    },
  },
},
emdashkits.com

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

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

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)