Outputs — the roads out
Data comes in so that something can leave: a Teams message at 02:01, a mail in the ADV's
inbox, a row in your n8n flow. How lumnik breathes tells that story;
this page is the manual for wiring it. The wiring commands (lm integration, lm notify,
lm outputs) are lm_admin surface; reading the decision pile (lm inbox) is every app
user's (lm_user).
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 event, best-effort | lm notify |
|
| Pull | the decision pile and the delivery journal, when you come asking | lm inbox · lm outputs |
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.- The signing secret rides stdin, never argv — it is stored encrypted and never
returned by
list/describe. testfires a catalog event (--event, defaultorg.created);decision.*types are dynamic and not testable this way — trigger the rule instead.- Forwarding starts at registration — nothing is delivered retroactively.
$ 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/webhook/lumnik decision.* true
What arrives, and how to verify it
One HTTP POST per event, JSON payload as in the registry, with 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>
Recompute the HMAC over the raw body bytes and compare — reject on mismatch. (n8n and
Make have ready-made HMAC-verification nodes; point them at the sha256=-stripped value.)
X-Lumnik-Event-Id is not covered by that signature — it rides alongside the signed
body, so treat it as best-effort correlation, not as a verified claim. It carries the
outbox event's own id, the same id lm inbox shows and a later ack echoes back as
annotation.decision.acked's decision_id — store a decision.* delivery keyed by this
id and you can resolve its ack without asking lumnik again. A replay of a delivery
carries the same id as the original attempt.
Delivery semantics
The outbox is swept every 5 s; a failed delivery is retried by a 30 s sweep, 3 attempts in total, per-endpoint — an endpoint that already succeeded is never re-delivered while another one retries. Every attempt lands in the journal:
lm outputs list # deliveries across every integration, failures first
lm integration describe <ID> # one target: config + its recent deliveries
A decision fired while the target was down — the journal says so, failures first:
$ lm outputs list
INTEGRATION EVENT STATUS WHEN ERROR
ops-automation decision.order-on-hold no response 2026-09-05 05:40 ConnectException: null
POST /api/platform/deliveries/{id}/replay. The outbox itself is purged daily (04:30,
LUMNIK_EVENTS_RETENTION_DAYS, default 30) — never past what the slowest consumer has seen.
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 — templates, variables and the delivery contract have their own page. The short version: mocked until the SMTP knobs are set, best-effort by design — when delivery must be provable, use the webhook.
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: the test fires a real org.created POST — the n8n execution list shows it
within seconds, 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 — note the delivery id
TOKEN=$(awk '/^ *access:/ {print $2; exit}' ~/.lm/config.yaml)
curl -s -X POST https://your-hub.example.com/api/platform/deliveries/<id>/replay \
-H "Authorization: Bearer $TOKEN"
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.
- Watching
data.row.changedon a REST/GraphQL/SOAP-ingested table? Those tables are event-silent today (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 kind: Process rules and
dormancy watches mint them under the enforced decision. prefix.
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."
payload:
entity: string
entity_key: string
op: string
run_id: string
- type: annotation.decision.acked
description: "A human treated a decision in the inbox."
payload:
decision_id: long
by: string
Two emission rules worth planning around:
data.row.changedis gated: it is emitted (in the ingestion transaction) only when at least one integration, rule or process subscribes to it — and only for jdbc/CSV/kafka ingestion; REST/GraphQL/SOAP-ingested tables are event-silent today (named limits).decision.*is the métier layer: minted by Processes and Workflow dormancy, 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