Meridian handbook
Integrating

Webhooks

How Meridian tells other systems that something happened: webhooks.yaml with every option and limit, the content events and the endpoint webhook step, the request body and headers, verifying the HMAC signature, retries and backoff, the delivery log, and replaying a delivery.

An outbound webhook is a signed JSON POST the engine sends to an address you declare, when content is published or changes, or when a declared endpoint runs a webhook step. Deliveries are queued and sent from the job runner, never inline, so a slow or failing receiver delays nothing a person or a publish is waiting on. Every attempt is written to a delivery log you can read and replay from.

A headless site’s revalidation webhooks (delivery.frontend.webhooks in site.yaml) use the same signing, retries, log and replay with a body of their own; they are described in Headless sites.

engine-cms behaves the same.

A minimal webhook

# config/sites/<site>/webhooks.yaml
webhooks:
  crm:
    url: https://hooks.example.org/meridian
    secret: ${CRM_WEBHOOK_SECRET}
    events: [content.published, content.changed]
    types: [event]
export CRM_WEBHOOK_SECRET="$(openssl rand -hex 32)"
engine-dmo check --fix --project .   # writes the webhook's id, then checks

From now on, each time a document of type event on this site is published, or anything changes where it is live, https://hooks.example.org/meridian receives a signed post.

webhooks.yaml

One file per site, config/sites/<site>/webhooks.yaml. Keys are lowercase letters, digits and hyphens. Each webhook gets a stable id (whk_...) written by engine-dmo check --fix; its delivery log is keyed by it, so renaming the key keeps the log. Rename one with engine-dmo dev webhook rename <old> <new>, which also updates every endpoint step that names it.

KeyDefaultLimitsMeaning
url(required)An absolute http or https URL, with no credentials in itWhere deliveries go. Written in configuration, never built from a request. A ${VAR} interpolation is allowed.
secret(required)A bare ${VAR} interpolation; the value at least 16 charactersThe HMAC key every delivery is signed with. A literal is a check error.
eventsnonecontent.published, content.changedThe content events this webhook receives. A webhook with none receives only what endpoint steps send it.
typesall typesType keys; needs eventsNarrows the content events to documents of these types.
timeout10sAt most 30sHow long one attempt waits for the receiver.
max_attempts81 to 20Attempts before a delivery is marked failed.

What triggers a delivery

Content events

EventWhendata
content.publishedA locale of a document goes live on a site this webhook belongs to, including a scheduled publish{"document": {"id", "type"}, "locale"}
content.changedAfter anything that changes where a document is live: publish, unpublish, archive, trash, restore, purge, and adding it to or removing it from a site{"document": {"id", "type"}, "placements": [{"site", "locale", "live"}]}: each of the document’s placements on this webhook’s site, and whether it is live there now

content.changed tells the receiver where to look again rather than what happened, as the search index is told: read the document from the content API to see its current state.

The endpoint webhook step

A declared endpoint can send to a webhook as one of its steps:

# config/sites/<site>/endpoints.yaml
endpoints:
  - key: refresh-events
    path: /api/refresh-events
    methods: [post]
    access: staff
    steps:
      - job: { name: importer.run, with: { importer: partner-events } }
      - webhook: { to: crm, event: events.refreshed, body: { importer: partner-events } }
    respond:
      location: /studio
KeyMeaning
toA webhook declared in this site’s webhooks.yaml.
eventThe event name: lowercase area.verb (enquiry.received). endpoint.<key> when left out.
bodyA mapping of names to references ($email, $listing.title) or literals, sent as data. Without it, data holds the endpoint’s resolved values. When an earlier store step saved a submission, data.submission is its id.

The step queues the delivery and never fails the endpoint: a receiver that is down costs nothing to the visitor.

The request

POST /meridian HTTP/1.1
Host: hooks.example.org
Content-Type: application/json
User-Agent: Meridian-Webhook
Meridian-Delivery: 0191f3a0-6c4e-7f12-9b1d-2a7e5c3d1f00
Meridian-Event: content.published
Meridian-Signature: t=1758017520,v1=5f2b0c...

{"id":"0191f3a0-6c4e-7f12-9b1d-2a7e5c3d1f00","event":"content.published","site":"main","occurred_at":"2026-09-16T10:12:00Z","data":{"document":{"id":"0191f0c2-7a51-7c3e-9d0a-5b2f6c1e4a88","type":"event"},"locale":"en-GB"}}
Body keyMeaning
idThe delivery’s id, the same as Meridian-Delivery.
eventThe event name, the same as Meridian-Event.
siteThe site’s key.
occurred_atWhen the event happened, RFC 3339 UTC.
dataThe event’s data, as above.

The body is fixed when the delivery is queued: every retry and every replay sends the same bytes under the same Meridian-Delivery id, newly signed. Deduplicate on that id.

Any 2xx answer is success. Anything else, a timeout or a connection failure is a failed attempt. A redirect is followed only to the same scheme and host. The first 2 KiB of the receiver’s answer is kept in the delivery log.

Verifying the signature

Meridian-Signature is t=<unix seconds>,v1=<hex>, where the hex is the HMAC-SHA256, keyed with the webhook’s secret, of the timestamp, a full stop and the raw body: HMAC_SHA256(secret, t + "." + body). A receiver must:

  1. Read the raw body before parsing it.
  2. Compute the HMAC over t + "." + body and compare it with v1 in constant time.
  3. Refuse a timestamp more than five minutes from its own clock, so a captured delivery cannot be replayed later.
func verify(secret, header string, body []byte, now time.Time) error {
	var ts, sig string
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch k {
		case "t":
			ts = v
		case "v1":
			sig = v
		}
	}
	unix, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || sig == "" {
		return errors.New("malformed signature")
	}
	m := hmac.New(sha256.New, []byte(secret))
	m.Write([]byte(ts + "."))
	m.Write(body)
	got, err := hex.DecodeString(sig)
	if err != nil || !hmac.Equal(got, m.Sum(nil)) {
		return errors.New("signature does not match")
	}
	if d := now.Sub(time.Unix(unix, 0)); d > 5*time.Minute || d < -5*time.Minute {
		return errors.New("signature is stale")
	}
	return nil
}
// Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, header, rawBody, now = Date.now()) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) throw new Error("malformed signature");
  const want = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest();
  const got = Buffer.from(parts.v1, "hex");
  if (got.length !== want.length || !timingSafeEqual(got, want)) throw new Error("signature does not match");
  if (Math.abs(now / 1000 - t) > 300) throw new Error("signature is stale");
}

Each attempt is signed when it is sent, with the secret the engine holds at that moment. To rotate a secret, set the new value in the engine’s environment and restart it, and have the receiver accept both secrets while you switch.

Retries

A failed attempt is retried with exponential backoff: about 2 seconds after the first failure, doubling each time, at most an hour apart, until max_attempts attempts have been made. The last failure marks the delivery failed in the log, and the engine logs a warning for each failed attempt. With the default 8 attempts, the retries span about four minutes, so a receiver that is down for longer than that needs a replay.

A delivery for a webhook that has since been removed from webhooks.yaml, or whose secret is no longer set, is marked failed without being sent.

A delivery moves through three states. Only a replay takes it out of delivered or failed, and a replay sends the same id and body again:

stateDiagram-v2
  [*] --> pending: event queued
  pending --> pending: attempt fails,<br/>retry after a backoff
  pending --> delivered: a 2xx answer
  pending --> failed: max_attempts used,<br/>or the webhook is gone
  delivered --> pending: replay
  failed --> pending: replay
  delivered --> [*]: kept 30 days
  failed --> [*]: kept 30 days

There is no alert beyond the warning logged for each failed attempt. Watch for those warnings, or list failed deliveries on a schedule (engine-dmo webhooks deliveries --status failed), and replay what a long outage lost.

The delivery log

Every delivery and its attempts are kept for 30 days: its status (pending, delivered or failed), the event, the body, the headers it was sent with (the signature shown as [redacted]), the receiver’s status code and the start of its answer.

engine-dmo webhooks deliveries --site main --project .
engine-dmo webhooks deliveries --site main --status failed --webhook crm --limit 50 --project .
engine-dmo webhooks deliveries --site main --id <delivery-id> --project .
FlagMeaning
--siteThe site; the default site when absent.
--statuspending, delivered or failed.
--webhookOne webhook, by its key or its config id.
--limitAt most this many, newest first, up to 500 (default 100).
--idPrint one delivery in full, with its headers, body and the receiver’s answer.

In the admin, the log is under the site’s Settings, Webhooks (<admin_path>/s/<site>/settings/webhooks) for staff holding webhooks.view.

Replaying a delivery

engine-dmo webhooks replay --site main --id <delivery-id> --project .

A replay sends a delivery again with the same id and body, newly signed, with a fresh set of attempts. It needs webhooks.replay on the site and is audited as webhook.replay. A delivery still pending cannot be replayed. Staff can also replay from the delivery’s page in the admin.