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:
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"
} | Key | Notes |
|---|---|
| event | The event name, from the table below. |
| data | The event-specific payload. Its shape depends on the event. |
| timestamp | ISO 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. |
collection and entry live inside data, not at
the top level. Reading body.entry gets you undefined;
you want body.data.entry.
Events
| Event | data | Fires 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.
{
"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.
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
{
"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.
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.
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)
}
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.
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)
})
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.
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
GETor aPUTresponse never includes header values, only a sortedheader_namesarray, 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,ConnectionandX-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.
{
"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"
}
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:
| format | Body shape |
|---|---|
| sedgemark | The default: the {event, data, timestamp} envelope described above. Unchanged from before this option existed. |
| github_repository_dispatch | Reshaped 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:
{
"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
| Question | Answer |
|---|---|
| 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
| Behavior | Detail |
|---|---|
| Success | Any 2xx. A non-2xx counts as a failure. |
| Timeout | 10 seconds per attempt. Acknowledge first, then do the work. |
| Retries | Up 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. |
| Redirects | Not followed. A 301 or 302 is a failure: register the final URL instead. |
| Ordering | Not guaranteed. Deliveries are concurrent and retried independently; use the entry’s own _updated_at rather than arrival order to decide what is newest. |
| Duplicates | Possible. A receiver that succeeds slowly enough to time out will be retried, so handlers should be idempotent. |
| Response body | Ignored entirely. Only the status code matters. |
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.updatedevents 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_msof up to one hour. Sedgemark then waits that long after the last matching event before delivering. Adebounce_scopeofentrycollapses per entry;webhookcollapses 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.
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
| Goal | Subscribe to | Notes |
|---|---|---|
| Rebuild a static site | content.published, content.unpublished, content.deleted | Set debounce_ms with scope "webhook" so a burst of edits triggers one build. |
| Keep a search index current | content.published, content.updated, content.unpublished, content.deleted | Index on publish/update, remove on unpublish/delete. Make the handler idempotent. |
| Route form submissions | form.submitted | The payload has no values: fetch them over MCP with a submissions:read key. |
| Notify on new drafts | content.created | Check data.entry._status to tell a new draft from something published immediately. |