v1 API

Integrations

Webhooks

Sedgemark can POST to a URL of yours whenever content changes, to rebuild a static site, forward a form submission, or keep a search index in step.

Setting one up

Add a webhook under Settings in the dashboard: a URL, the events you want, and an active toggle. Sedgemark generates a signing secret for you.

  • HTTPS only. An http:// URL is rejected when you save it.
  • Public addresses only. Loopback, link-local and private ranges are refused, so a webhook cannot be pointed at infrastructure that is not reachable from the internet. This also means you cannot deliver to a local listener during development , so use a tunneling service that gives you a public HTTPS hostname.
  • The secret is stored encrypted and decrypted only at delivery time. Managing webhooks is an owner-level action.

What arrives

Every delivery is a POST with the same three-key envelope, whatever the event:

http
POST https://your-endpoint.example.com/sedgemark
Content-Type: application/json
X-Sedgemark-Signature: sha256=6d2f8b1c…

{
  "event": "content.published",
  "data": { /* varies by event, see below */ },
  "timestamp": "2026-08-04T15:04:05.000Z"
}
KeyNotes
eventThe event name, from the table below.
dataThe event-specific payload. Its shape depends on the event.
timestampISO 8601, generated when the request is built, so for a debounced or retried delivery this is the send time, not the time of the change.
The event fields are nested under data

collection and entry live inside data, not at the top level. Reading body.entry gets you undefined; you want body.data.entry.

Events

EventdataFires when
content.created{ collection, entry }An entry is created, in any status. A new entry created as published also fires content.published.
content.updated{ collection, entry }An existing entry is saved without its status changing.
content.deleted{ collection, entry }An entry is deleted. The entry as it was at deletion is included.
content.published{ collection, entry }An entry moves from draft to published.
content.unpublished{ collection, entry }An entry moves from published back to draft.
form.submitted{ form, formId, submission }A form submission is accepted and stored. A submission that trips the honeypot fires nothing.

Content events

entry is the stored row, including all the system columns.

json
{
  "event": "content.published",
  "data": {
    "collection": "blog_post",
    "entry": {
      "id": "9f8c1a2b-4d5e-4f60-8a71-2c3d4e5f6071",
      "title": "Hello, world",
      "body": "<p>My first post.</p>",
      "_status": "published",
      "_published_at": "2026-08-04T15:04:05.000Z",
      "_created_by": "3b1e7c40-88a2-4d19-9f5c-6a0b1d2e3f40",
      "_created_at": "2026-08-04T14:58:11.000Z",
      "_updated_at": "2026-08-04T15:04:05.000Z"
    }
  },
  "timestamp": "2026-08-04T15:04:05.000Z"
}

Note that content.deleted and content.unpublished also carry the whole entry, not just an id. For a deletion that is the only remaining record of what was removed.

Many-to-many relation fields are missing from entry

A many-to-many relation lives in a join table and is assembled when an entry is read. The webhook payload is the row as written, so those fields are absent entirely, not [], not null, simply not a key.

Single (one-to-many) relations are a plain column and are present as normal. If your handler needs the many-to-many values, re-read the entry from the Content API using data.entry.id.

Form submissions

json
{
  "event": "form.submitted",
  "data": {
    "form": {
      "id": "c14f2e70-9a3b-4c5d-8e6f-0a1b2c3d4e5f",
      "slug": "contact",
      "name": "Contact us"
    },
    "formId": "c14f2e70-9a3b-4c5d-8e6f-0a1b2c3d4e5f",
    "submission": {
      "id": "5a1f3c88-2b47-4e19-90d6-7c8e9f0a1b2c",
      "_submitted_at": "2026-08-04T15:04:05.000Z"
    }
  },
  "timestamp": "2026-08-04T15:04:05.000Z"
}

formId duplicates form.id. Both are sent; prefer form.slug for routing, since it is the name you gave the form and the one that appears in its URL.

The submitted values are not in the payload

submission carries only the new submission’s id and _submitted_at. The field values a visitor actually typed are not included.

Treat this event as a notification, “something arrived, here is its id”, and read the values from the dashboard inbox or over MCP with a submissions:read key. See Forms. A handler expecting an email address in this payload will find nothing there.

Verifying the signature

Every delivery carries an X-Sedgemark-Signature header: the string sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your webhook’s secret.

Your endpoint is a public URL, so anyone can POST to it. Verify every request before acting on it, and reject anything that fails.

javascript
import { createHmac, timingSafeEqual } from 'node:crypto'

const SECRET = process.env.SEDGEMARK_WEBHOOK_SECRET

export function verify(rawBody, signatureHeader) {
  // Compute over the RAW body bytes, exactly as received. Parsing to an object
  // and re-stringifying reorders and reformats it, and the signature will not
  // match.
  const expected = 'sha256=' + createHmac('sha256', SECRET).update(rawBody).digest('hex')

  const a = Buffer.from(expected)
  const b = Buffer.from(signatureHeader ?? '')

  // timingSafeEqual throws on a length mismatch, so check that first.
  return a.length === b.length && timingSafeEqual(a, b)
}
Compute over the raw bytes

The single most common mistake is verifying against a re-serialized body. Parsing JSON and calling JSON.stringify on the result changes key order and whitespace, producing a different hash every time. Capture the raw body before any parsing middleware touches it.

javascript
import express from 'express'

const app = express()

// express.raw, not express.json: the verifier needs the untouched bytes.
app.post('/sedgemark', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body, req.get('X-Sedgemark-Signature'))) {
    return res.sendStatus(401)
  }

  const { event, data, timestamp } = JSON.parse(req.body.toString('utf8'))

  // Acknowledge immediately; do the work afterwards. Sedgemark gives up on a
  // response after 10 seconds and retries.
  res.sendStatus(200)

  void handle(event, data, timestamp)
})
The signature does not cover the timestamp

It is computed over the body alone. The timestamp field is inside that body and therefore protected from tampering, but there is no separate signed timestamp in the header, so the signature by itself is not replay protection, unlike schemes that sign {timestamp}.{body} together.

Since retries mean a legitimate delivery can arrive twice anyway, the right defence is the same one: make your handler idempotent, keyed on data.entry.id or data.submission.id.

Custom headers

A webhook can carry up to 10 custom name/value pairs, sent on every delivery, most often an Authorization header for a receiver that expects one, like GitHub’s repository_dispatch API below.

http
POST /api/dashboard/webhooks
Content-Type: application/json

{
  "url": "https://api.github.com/repos/acme/site/dispatches",
  "events": ["content.published", "content.unpublished"],
  "format": "github_repository_dispatch",
  "headers": {
    "Authorization": "Bearer ghp_your_pat_here",
    "Accept": "application/vnd.github+json"
  }
}
  • Stored encrypted, read back as names only. A GET or a PUT response never includes header values, only a sorted header_names array, the same shape the webhook’s secret already follows. There is no endpoint that returns a value you set once you have set it.
  • A fixed set of names is refused. Host, Content-Type, Content-Length, Transfer-Encoding, Connection and X-Sedgemark-Signature (case-insensitively) can never be set as a custom header, since the dispatcher sets every one of them itself on every delivery and a custom header is never allowed to override them.
  • Names must be a legal HTTP header token; values are capped at 1024 characters and may not contain control characters, including a raw CR or LF, which are rejected outright rather than stripped.
json
{
  "id": "c14f2e70-9a3b-4c5d-8e6f-0a1b2c3d4e5f",
  "url": "https://api.github.com/repos/acme/site/dispatches",
  "events": ["content.published", "content.unpublished"],
  "active": true,
  "format": "github_repository_dispatch",
  "header_names": ["Accept", "Authorization"],
  "created_at": "2026-08-04T14:58:11.000Z"
}
On update, a blank value means “keep the stored one”

Since a value is never returned, there is no way to send back what you cannot see. PUT with { "headers": { "Authorization": "" } } leaves that header exactly as it was; only the other keys you update need repeating. Sending headers: null, {}, or a blank value for every stored name all clear the webhook’s custom headers entirely. A blank value for a name that has never been set is a 400: there is nothing to keep.

Payload format

A webhook’s optional format changes the shape of the delivered body, never which events it fires for; that is still events. There are 2:

formatBody shape
sedgemarkThe default: the {event, data, timestamp} envelope described above. Unchanged from before this option existed.
github_repository_dispatchReshaped to satisfy GitHub's repository_dispatch API. See below.

github_repository_dispatch exists because GitHub’s POST /repos/{owner}/{repo}/dispatches rejects any body missing an event_type key with a 422, and caps client_payload at 10 top-level properties. The full entry payload would not fit and would not be accepted anyway, so this format sends a small, fixed shape instead of the whole entry:

json
{
  "event_type": "sedgemark.content.published",
  "client_payload": {
    "event": "content.published",
    "collection": "blog_post",
    "entryId": "9f8c1a2b-4d5e-4f60-8a71-2c3d4e5f6071",
    "timestamp": "2026-08-04T15:04:05.000Z"
  }
}

X-Sedgemark-Signature is still computed and sent, over this reshaped body; verification works exactly the same as for the default format.

This is one half of triggering a CI rebuild from published content. The full recipe (the GitHub PAT’s scope, the Actions workflow, and where sedgemark deploy fits) is Auto-rebuild on publish.

Operating a webhook

QuestionAnswer
Where is the signing secret?Generated for you when you create the webhook, and shown in the dashboard on the webhook’s own settings.
Can I rotate it?Not in place. Create a replacement webhook with the new secret, move your receiver over, then delete the old one.
Is there a delivery log?No. Failures are recorded in the platform’s server logs, which are not exposed in the dashboard, so build your own logging on the receiving side if you need an audit trail.
Can I replay a delivery?No. There is no replay control and no dead-letter queue.
What IP will requests come from?There is no published source range or dedicated User-Agent, so allowlist by signature verification rather than by network origin.

Delivery behavior

BehaviorDetail
SuccessAny 2xx. A non-2xx counts as a failure.
Timeout10 seconds per attempt. Acknowledge first, then do the work.
RetriesUp to 3 attempts, waiting 1s then 2s. After that the delivery is dropped and logged: there is no dead-letter queue and no manual replay.
RedirectsNot followed. A 301 or 302 is a failure: register the final URL instead.
OrderingNot guaranteed. Deliveries are concurrent and retried independently; use the entry’s own _updated_at rather than arrival order to decide what is newest.
DuplicatesPossible. A receiver that succeeds slowly enough to time out will be retried, so handlers should be idempotent.
Response bodyIgnored entirely. Only the status code matters.
Deliveries do not survive a restart

Everything pending is held in memory. On a graceful shutdown Sedgemark makes a best-effort attempt to flush debounced deliveries before exiting, but a delivery already sitting in its retry backoff is not tracked for that, and is simply lost, as is anything at all on a hard crash.

Webhooks are a fast notification channel, not a durable queue. If a missed event would be a correctness problem, reconcile periodically by reading the API rather than relying on having received every event.

Debouncing

Someone editing an entry in the dashboard generates a burst of saves. Two mechanisms collapse those into one delivery.

  • Always on: content.updated events for the same entry within half a second are coalesced into a single delivery carrying the latest state. No other event is affected, and there is nothing to configure.
  • Optional, per webhook: set a debounce_ms of up to one hour. Sedgemark then waits that long after the last matching event before delivering. A debounce_scope of entry collapses per entry; webhook collapses everything for that webhook and event into one delivery, which is what you want for a “rebuild the site” hook, where fifty changes should cause one build.

Both are configured in the dashboard, not over an API. They stack only where they overlap: for content.updated, a webhook with a debounce set sees an effective delay of roughly that value plus the half-second coalescing window. Every other event skips the coalescing step entirely, so its delay is exactly the value you set.

Debouncing drops intermediate states, by design

A debounced delivery carries the payload from the most recent event in the window; the ones it replaced are never sent. If your handler needs every individual change (an audit log, say), leave debounce_ms unset. Note that even then, the always-on content.updated coalescing still applies.

Common setups

GoalSubscribe toNotes
Rebuild a static sitecontent.published, content.unpublished, content.deletedSet debounce_ms with scope "webhook" so a burst of edits triggers one build.
Keep a search index currentcontent.published, content.updated, content.unpublished, content.deletedIndex on publish/update, remove on unpublish/delete. Make the handler idempotent.
Route form submissionsform.submittedThe payload has no values: fetch them over MCP with a submissions:read key.
Notify on new draftscontent.createdCheck data.entry._status to tell a new draft from something published immediately.