Documentation
Running Smeldr

Webhooks and event subscriptions

Smeldr can tell another system when something changes, in two ways:

  • A webhook. Smeldr sends each event as a signed HTTPS POST to a URL you register. Use it when the receiver is a server.
  • The event stream. Your client holds a connection open and reads events as they happen. Event subscriptions decide which events it gets.

Turn webhooks on

Set ENABLE_WEBHOOKS before you start the server. It creates the tables smeldr_webhook_endpoints, smeldr_outbound_jobs and smeldr_delivery_logs if they do not exist.

Each endpoint's signing secret is stored encrypted with a key derived from SECRET. If you change SECRET, every stored webhook secret stops working, and the endpoints have to be registered again.

Register an endpoint

Registering an endpoint needs an admin token. Name the URL and the events it should get:

./smeldr-cli webhook create --url https://hooks.example.com/smeldr \
  --events decision.created,decision.transitioned,signal.created

The answer holds the endpoint's id and its signing secret. The secret is shown once and cannot be read again, so store it right away.

The URL must use HTTPS, and it must not point at localhost, a .local name or a private IP address. The server checks this when you register the endpoint and again each time it connects, and it does not follow redirects. A refused URL says why:

smeldr-cli: MCP error -32602: validation failed: url: must not target a private IP address

Event names

An event name is the item's type in lower case, a dot and what happened:

  • <type>.created, <type>.updated, <type>.published, <type>.unpublished, <type>.scheduled, <type>.archived and <type>.deleted, for content items, such as post.published;
  • <type>.transitioned, for every change of state, such as decision.transitioned when a Decision is ratified;
  • decision.created, task.created and signal.created when such an item is created.

An endpoint gets exactly the names it lists. There is no wildcard.

What arrives

Each delivery is a POST with a JSON body and these headers:

HeaderValue
X-Smeldr-Eventthe event name, such as decision.transitioned
X-Smeldr-Deliverythe delivery's ID, the same on every retry of it
X-Smeldr-TimestampUnix seconds, part of what is signed
X-Smeldr-Signaturesha256= and the hex HMAC-SHA256 of the timestamp, a dot and the body

A Decision being ratified arrives like this:

{"id":"01a1afc5-7e61-7c7e-9da9-00e4db4a80c0","event":"decision.transitioned","timestamp":"2026-11-06T07:31:58.17703849Z","data":{"type":"decision","id":"01a1afc5-7ded-7f24-9ee9-a65de6ba15de","slug":"we-never-log-session-tokens","address":"D1","from_state":"proposed","to_state":"ratified","reason":"Agreed","actor_id":"01a1afc5-7d58-7602-b850-a8550be89bbb","actor_kind":"human"}}

actor_kind says what kind of actor caused the change: human, agent, job or unclassified. Both actor keys are left out when no actor is known.

Verify the signature

Compute the HMAC-SHA256 of the X-Smeldr-Timestamp value, a dot and the raw body, with the endpoint's secret, and compare it with X-Smeldr-Signature. In a shell, with the timestamp in $ts, the body in body.json and the secret in $SECRET:

calc="sha256=$( (printf '%s.' "$ts"; cat body.json) | openssl dgst -sha256 -hmac "$SECRET" | sed 's/.*= //')"
[ "$calc" = "$sig" ] && echo valid

In your receiver, compare in constant time, use the body exactly as it arrived, and reject a timestamp older than a few minutes (five, say: the limit is your choice, Smeldr sets none).

Retries and duplicates

A delivery counts as done when the receiver answers with a 2xx status within 30 seconds. Otherwise it is tried again, with a delay that grows four times per attempt, up to an hour apart, and after seven failed attempts it is marked dead. A delivery that is not done within 24 hours is not tried again. After five failures in a row, an endpoint is skipped for five minutes.

The same delivery can arrive more than once. Use X-Smeldr-Delivery to recognise one you have already handled, and answer 2xx to it again.

To see what happened:

./smeldr-cli webhook list
./smeldr-cli webhook deliveries --endpoint <endpoint-id>
./smeldr-cli webhook retry <job-id>
./smeldr-cli webhook delete <endpoint-id>

webhook list shows each endpoint with its successful and failed deliveries. webhook deliveries shows the pending jobs of an endpoint, or the attempts of one job with --job. webhook retry queues a dead job again.

The event stream

With ENABLE_EVENT_STREAM, a client reads events from GET /_events/stream with its token, as one JSON object per line, with a {"type":"ping"} line in between to keep the connection open:

curl -sN -H "Authorization: Bearer $SMELDR_TOKEN" http://localhost:8080/_events/stream

With ENABLE_SUBSCRIPTIONS as well, the connection delivers the token's event subscriptions. Every token has three to begin with: requests addressed to it, conditions it can resolve, and the outcomes of its own requests. ./smeldr-cli subscription list shows them. A subscription you add names a filter: item types, events, states, items, and routing values such as a Task's band.

How webhooks and subscriptions fit together

With ENABLE_SUBSCRIPTIONS, a webhook endpoint is delivered as a subscription. Each new endpoint gets one, with its event names as the filter, and an event goes out only when its owner may read what the event shows. An endpoint registered before subscriptions were on keeps getting every event it names. To give such endpoints their subscription:

./smeldr-server migrate-webhook-subscriptions          # a dry run: shows what it would do
./smeldr-server migrate-webhook-subscriptions --apply

Every setting named here is in Configuration and environment variables.