Outputs — the roads out
How to wire what leaves the hub: a webhook to n8n or Teams, a mail, a pull. Why and when:
How lumnik breathes. In lm ui: :integrations, :notify and :outputs
(the terminal console).
| Command | Roles needed |
|---|---|
lm integration, lm outputs |
lm_integrator and lm_admin |
lm notify |
lm_admin |
lm inbox |
lm_user |
$ lm integration list # as a user holding lm_admin but not lm_integrator
Error: API error 403: missing role lm_integrator
Wiring is tenant-wide: a webhook or an email rule receives every matching event of the tenant.
An admin holding any scope:* role is refused lm integration, lm notify, lm outputs and the
live event stream with Error: access refused (403): scope not granted.
The three roads out
| Road | Carries | Command family |
|---|---|---|
| Webhook | every subscribed event, signed, retried, journaled, replayable | lm integration |
| events matching a rule, one mail per rule and event, retried, journaled, replayable | lm notify |
|
| Pull | the decision pile and the delivery journal, when you come asking | lm inbox · lm outputs |
A transaction left open holds events back. A road takes an event only once every older database transaction has ended.
- One write left open holds back the events of every tenant on the hub. Nothing is lost: they leave when it ends.
- This includes another application's database on the same PostgreSQL server.
- After two minutes the hub log says so:
… event(s) have waited more than 2 minutes for an older database transaction to end. Look for a database session left idle in transaction.
Wire a webhook
echo -n "$SIGNING_SECRET" | lm integration add crm-bridge \
--url https://n8n.example.com/webhook/lumnik \
--events 'decision.*' \
--secret-stdin
lm integration test <ID> # fires a real POST at the target, result inline
--eventstakes exact types or a prefix ending in.*(defaultdecision.*). The registry below is the vocabulary.decision.*reachesdecision.plus something. A*anywhere else, likedecision.cold-room-*, is refused: it would never match.- The signing secret rides stdin, never argv — it is stored encrypted and never
returned by
list/describe. -
testfires a sample event (org.created) at the target and prints the result:$ lm integration test 1000 delivered in 4ms (HTTP 200) $ lm integration test 1001 Error: delivery failed: connection failed (ConnectException)A decision cannot be tested this way: make a process fire instead. - Forwarding starts at registration — nothing is delivered retroactively. - Without
--secret-stdinthe hub refuses the registration (400) and stores nothing. -listanddescribeshow the target's scheme and host only: a webhook URL's path and query are often its credential.-o json/-o yamlprint the hub's body as it is, full URL included — that is the machine contract, and you asked for it.
$ echo -n "$SIGNING_SECRET" | lm integration add n8n \
--url https://n8n.example.com/webhook/lumnik --events 'decision.*' --secret-stdin
registered: n8n (id 1001) — forwards subscribed events from now on
$ lm integration list
ID NAME URL EVENTS ENABLED
1001 n8n https://n8n.example.com decision.* true
What arrives, and how to verify it
One HTTP POST per event. The body is the event's JSON: a decision's, as n8n receives it, is on
Processes. With it come three headers:
X-Lumnik-Event: decision.commande-bloquee
X-Lumnik-Event-Id: 3639
X-Lumnik-Signature: sha256=<hex HMAC-SHA256 of the raw request body, keyed with your secret>
- Verify: recompute the HMAC over the raw body bytes and compare; reject on mismatch.
n8n and Make have HMAC-verification nodes — give them the value after
sha256=. X-Lumnik-Event-Idis the idlm inboxshows, and thedecision_ida laterannotation.decision.ackedcarries. A replay keeps it. It is not signed.
Delivery semantics
- An event leaves within about 5 seconds.
- A failed delivery is tried 3 times in all, about 30 seconds apart. Then you replay it.
- An event can arrive twice (a hub stopped mid-send): deduplicate on
X-Lumnik-Event-Id. The test POST carries no event id.
Every attempt lands in the journal:
lm outputs list # deliveries across every integration, failures first
lm integration describe <ID> # one target's registration; :integrations adds its recent deliveries
$ lm outputs list
ID INTEGRATION EVENT STATUS WHEN ERROR
1003 fake-n8n decision.litige-ouvert ok 2026-10-24 23:38
1002 fake-n8n decision.overdue.acme-delivery-lifecycle ok 2026-10-24 23:33
Failures come first, with the reason in ERROR (connection failed (ConnectException) for a
target that is down). Replay one, with lm outputs replay ID or ^R on :outputs in lm ui:
$ lm outputs replay 1001
replayed: delivery 1001 — a new attempt is on record
| What | Kept | Setting | Purged |
|---|---|---|---|
| delivery journal (payload + receiver's response) | 90 days; 0 = forever |
LUMNIK_WEBHOOKS_DELIVERY_RETENTION_DAYS |
nightly — a purged delivery cannot be replayed |
| events | 30 days | LUMNIK_EVENTS_RETENTION_DAYS |
nightly |
Route events to email
$ lm notify add --events 'decision.*' --to adv@example.com --template decision-alert
rule 1000: decision.* → adv@example.com
$ lm notify list
ID EVENTS RECIPIENT TEMPLATE ENABLED
1000 decision.* adv@example.com decision-alert true
One mail per matching event, rendered through a template that can reach into the payload —
see Email notifications. Mocked until the SMTP knobs are set. A failed send is tried
three times, then given up: lm notify deliveries shows it and lm notify replay ID sends it again.
How to…
Wire n8n in two minutes
In n8n, add a Webhook node and copy its production URL. Then:
echo -n "$SIGNING_SECRET" | lm integration add n8n \
--url https://n8n.example.com/webhook/lumnik --events 'decision.*' --secret-stdin
lm integration test <ID>
Result: delivered in …ms (HTTP 200), and the n8n execution list shows the POST, with
X-Lumnik-Event and the sha256= signature ready for an HMAC node.
Replay a delivery that exhausted its attempts
lm outputs list # failures first — the ID column
lm outputs replay <ID>
Result: one fresh attempt at the same target with the same payload; the outcome lands in the journal like any delivery.
Nothing arrives — the four checks
- Registered after the event? Forwarding starts at registration — nothing is retroactive. Trigger the event again.
- Expecting a process to fire on rows that existed before it was applied? Only later inserts and source changes fire it (named limits).
- Was anything subscribed at ingest time?
data.row.changedis only emitted when a subscription exists when the row lands — wire first, then re-run the ingestion. - Is it the pipe or the event?
lm integration test <ID>proves the pipe;lm outputs listgives the journal's verdict on every real attempt.
The event registry
The hub's governed vocabulary — what --events patterns match against and what each
payload carries. decision.<name> types are dynamic: your processes mint them, and your
workflows mint decision.dormant.<name>, decision.overdue.<name> and
workflow.undeclared.<name>. A workflow decision carries fields like a Process's when its
message cites columns or it declares some (what a decision carries).
Every event type, from the registry the hub ships (events.yaml)
module: core
version: "0.1"
events:
- type: org.created
description: "A new organisation was created."
payload:
orgId: long
tenantId: long
name: string
- type: org.updated
description: "An organisation was updated."
payload:
orgId: long
tenantId: long
- type: org.moved
description: "An organisation's parent changed."
payload:
orgId: long
tenantId: long
newParentId: long
- type: org.archived
description: "An organisation was archived."
payload:
id: long
code: string
- type: connector.run.started
description: "A connector endpoint run started."
payload:
run_id: string
connector_id: string
endpoint_id: string
sync_mode: string
trigger: string
- type: connector.run.completed
description: "A connector endpoint run completed; carries the conservation counters."
payload:
run_id: string
endpoint_id: string
records_in: long
records_out: long
duration_ms: long
tables: map
- type: connector.run.partial
description: "A connector endpoint run ended below the target it gave itself: it wrote everything it read, and did not read everything there was."
payload:
run_id: string
endpoint_id: string
records_in: long
records_out: long
duration_ms: long
tables: map
error_message: string
- type: connector.run.cancelled
description: "A connector endpoint run was cancelled."
payload:
run_id: string
- type: connector.run.failed
description: "A connector endpoint run failed."
payload:
run_id: string
error_message: string
retryable: boolean
- type: platform.secret.created
description: "A platform secret was created."
payload:
name: string
by: string
at: string
- type: platform.secret.updated
description: "A platform secret's value was rotated."
payload:
name: string
by: string
at: string
previousRotatedAt: string
- type: platform.secret.deleted
description: "A platform secret was deleted."
payload:
name: string
by: string
at: string
- type: platform.secret.read
description: "A platform secret was read from the database (cache misses only)."
payload:
name: string
by: string
at: string
- type: data.row.changed
description: "A genuinely new or changed row landed in a watched ingest table; emitted in the ingestion transaction. op is technical provenance; row_id (the row's own id, a string) only for rows of hybrid (REST) tables."
payload:
entity: string
entity_key: string
op: string
run_id: string
row_id: string
- type: annotation.decision.acked
description: "A human treated a decision in the inbox."
payload:
decision_id: long
by: string
- type: notification.mail.given-up
description: "A mail was given up after its attempts (or at once, when its template failed). Carries the kind of failure, never the mail; lm notify replay ID sends it again."
payload:
delivery_id: long
rule_id: long
event_type: string
failure: string
Three emission rules worth planning around:
data.row.changedis emitted only when an integration, rule or process subscribes to it, and only for jdbc, CSV, kafka and REST ingestion (named limits).notification.mail.given-upis emitted once for each mail given up. A webhook can carry it. A mail rule never receives it: a mail does not tell about mails.decision.*is the business-event layer: minted by Processes and Workflows (dormancy, due dates), treated in the Inbox —annotation.decision.ackedcloses the loop with who and when.
See also
- How lumnik breathes — the story these roads serve
- Processes — react · Workflows — declare · Inbox — decide
- Observability — every consumer clock on one page
- CLI reference — the full flag surface