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
POSTto 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>.archivedand<type>.deleted, for content items, such aspost.published;<type>.transitioned, for every change of state, such asdecision.transitionedwhen a Decision is ratified;decision.created,task.createdandsignal.createdwhen 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:
| Header | Value |
|---|---|
X-Smeldr-Event | the event name, such as decision.transitioned |
X-Smeldr-Delivery | the delivery's ID, the same on every retry of it |
X-Smeldr-Timestamp | Unix seconds, part of what is signed |
X-Smeldr-Signature | sha256= 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.