Documentation Index
Fetch the complete documentation index at: https://docs.nuon.co/llms.txt
Use this file to discover all available pages before exploring further.
Org-scoped webhooks let you subscribe to workflow and workflow step lifecycle events for your Nuon
Org. Whenever a workflow runs (for example, an Install provision, a
Sandbox reprovision, or a component deploy), Nuon POSTs a
CloudEvents v1.0 envelope to every webhook URL registered on the Org.
Webhooks are scoped to the current Org. Manage them with the Nuon CLI or the dashboard.
Manage webhooks
# List webhooks for the current Org
nuon orgs webhooks list
# Create a webhook (URL is required, secret is optional)
nuon orgs webhooks create --url https://example.com/webhooks/workflow/lifecycle --secret <shared-secret>
# Update a webhook's subscription (interests + match) and/or rotate its signing secret
nuon orgs webhooks update --webhook-id <webhook_id> --subscription-file ./subscription.json
nuon orgs webhooks update --webhook-id <webhook_id> --secret <new-shared-secret>
# Delete a webhook
nuon orgs webhooks delete --webhook-id <webhook_id>
A few constraints to be aware of:
- The
--url must be an absolute http or https URL with a host.
- Webhook URLs are unique per Org per scope. The uniqueness key is
(org_id, webhook_url, match) — the same URL can be registered multiple times in one Org as long as each
registration uses a different match predicate (for example, once for “all installs”
and again for “components in env=prod”). Registering the exact same (url, match) twice returns a conflict.
- The
--secret is write-only. The API never returns it; responses include has_secret: true|false so you can tell
whether one is configured.
- The webhook URL is part of the
(org_id, webhook_url, match) unique index and cannot be changed in place. To rename
it, delete the webhook and create a new one. Use the dashboard’s edit action (or
PATCH /v1/orgs/current/webhooks/{webhook_id}) to rotate the signing secret or replace the
interests filter / match predicate without
recreating the webhook.
Filtering events with interests
By default a new webhook receives every supported workflow, deploy, sandbox, runner, and approval event for the
Org. To narrow that down, attach an interests filter on create or update. The filter is a structured object stored
on each webhook and persisted as JSONB on the webhook row.
If you omit interests entirely (and on legacy webhooks created before this filter shipped), Nuon treats the
configuration as {"all_events": true} so deliveries don’t silently stop. The list endpoint surfaces this same
effective shape so the CLI/dashboard/SDK stay in sync with what’s actually delivered.
Top-level shape
A configuration is one of two shapes:
{
"resources": {
"installs": { "outcome": "completion", "approval_requests": true, "approval_responses": true },
"components": { "ops": ["deploy"], "outcome": "all", "drift_detected": true },
"sandboxes": { "outcome": "failures", "drift_detected": true },
"install_configurations": {},
"runners": { "ops": ["provision"] }
}
}
all_events: true is the new-subscription default and short-circuits everything else. To opt into a per-resource
configuration, omit all_events (or set it to false) and populate resources. A resource missing from resources
is off — no events of that kind are delivered.
Resource vocabulary
Each resource accepts a list of sub-ops drawn from a fixed vocabulary:
| Resource | Supported ops |
|---|
installs | provision, deprovision, reprovision |
components | deploy, teardown |
sandboxes | provision, reprovision, deprovision |
install_configurations | inputs, secrets |
runners | provision, reprovision, inactive |
actions | run |
Drift workflow lifecycle events (drift_run, drift_run_reprovision_sandbox) are intentionally not part of the
ops vocabulary — every cron tick fires a started/completed pair, which is pure noise on a clean scan. To subscribe to
drift, set drift_detected: true on components and/or sandboxes (see below).
Per-resource fields
| Field | Type | Default | Description |
|---|
ops | []string | [] | Sub-ops to subscribe to. An empty (or omitted) list means every sub-op for the resource. |
outcome | string | "all" | One of all, completion, failures, or none. Filters lifecycle events on terminal status; none mutes lifecycle for this resource entirely. |
approval_requests | bool | false | Deliver workflow-step approval requests for this resource. |
approval_responses | bool | false | Deliver workflow-step approval responses (approved / rejected) for this resource. |
drift_detected | bool | false | Deliver a notification only when drift is actually detected during a drift scan. Only meaningful for components and sandboxes. |
outcome semantics:
all — every started + terminal event for the matched ops.
completion — terminal events only (succeeded, failed, cancelled); suppress started.
failures — only failed and cancelled terminal events.
none — mutes lifecycle events entirely for this resource. Drift and approval events are gated independently
and remain functional, so none is the right choice when you want a drift-only or approvals-only subscription
for this resource.
Approval events do not have a started/succeeded/failed lifecycle, only a requested / approved / rejected handshake.
They are gated independently of outcome by the approval_requests and approval_responses booleans.
Drift detection
To receive drift notifications, set drift_detected: true on components and/or sandboxes. The
notification fires once per component (inside drift_run) or once per sandbox (inside
drift_run_reprovision_sandbox) whenever the plan-only check finds non-no-op changes. It works for
both manually triggered and scheduled drift scans, and is gated independently of outcome — the
same way approval_requests / approval_responses are.
Runner inactivity
Runners emit a runners.inactive lifecycle event when the control plane gives
up on a runner-process — currently this fires after roughly 5 minutes of
silence on the heartbeat channel for that process. It does not distinguish
a true outage from routine churn (process restart, version upgrade, deliberate
shutdown), so subscribers should expect to see it on normal operations as well.
Subscribe by including inactive in the runners ops list (or by leaving
ops empty to receive every runner sub-op).
Examples
Subscribe to every supported event (this is the default if interests is omitted):
Per-resource opt-in matching the dashboard’s “opted-out-of-AllEvents” baseline — terminal events plus approval
notifications for the four most common resources:
{
"resources": {
"installs": { "outcome": "completion", "approval_requests": true, "approval_responses": true },
"components": { "outcome": "completion", "approval_requests": true, "approval_responses": true, "drift_detected": true },
"sandboxes": { "outcome": "completion", "approval_requests": true, "approval_responses": true, "drift_detected": true },
"install_configurations": { "outcome": "completion", "approval_requests": true, "approval_responses": true }
}
}
Narrowly scoped — only component deploy failures:
{
"resources": {
"components": { "ops": ["deploy"], "outcome": "failures" }
}
}
Drift only — components, no lifecycle, no approvals:
{
"resources": {
"components": { "outcome": "none", "drift_detected": true }
}
}
Setting interests via the CLI
nuon orgs webhooks create and nuon orgs webhooks update take the full subscription — both the
interests filter and the optional match predicate
— as a single JSON document via --subscription-json (inline) or --subscription-file (path on disk). The two flags
are mutually exclusive. Omit both to fall back to the default: every supported event in the Org, no scope.
# Default — subscribe to every supported event in the Org
nuon orgs webhooks create --url https://example.com/hook
# Inline JSON (interests only)
nuon orgs webhooks create --url https://example.com/hook \
--subscription-json '{"interests":{"resources":{"components":{"ops":["deploy"],"outcome":"failures"}}}}'
# Inline JSON (interests + match — scope to two specific installs)
nuon orgs webhooks create --url https://example.com/hook \
--subscription-json '{"interests":{"all_events":true},"match":{"installs":{"ids":["ins_abc","ins_def"]}}}'
# From a file (recommended for non-trivial configs)
nuon orgs webhooks create --url https://example.com/hook --subscription-file ./subscription.json
# Replace the subscription on an existing webhook
nuon orgs webhooks update --webhook-id <webhook_id> --subscription-file ./subscription.json
The wire shape is {"interests": {...}, "match": {...}} — both keys are optional. A missing interests defaults to
{"all_events": true} so you can scope a webhook with --subscription-json '{"match":{...}}' without restating the
events filter. A missing or null match is the org-wide (“every entity”) subscription.
update replaces both interests and match wholesale — it is not a deep merge. Pass --secret <new> to rotate the
signing secret at the same time; omit --secret to leave the existing secret unchanged. The webhook URL plus its
match predicate together form the (org_id, webhook_url, match) unique index — delete and recreate to rename, or
register a sibling row with a different match to deliver the same URL with a different scope.
Setting interests via the dashboard
The dashboard’s webhook create + edit forms expose the interests filter as an
inline picker. Toggling Send all events off materializes the per-resource baseline shown above so you land on a
sensible starting point instead of an empty config that silently drops every event. Saving the form persists the same
JSON shape documented here.
Match in the dashboard. The dashboard’s webhook forms do not yet expose the
match predicate, and saving from the dashboard resets the webhook to org-wide.
To create or preserve a scoped webhook, manage it from the CLI.
Scoping deliveries with match
By default a webhook is org-wide — it fires for every event the
interests filter opts into, regardless of which install, component, or action
triggered it. Attach a match predicate to scope deliveries to specific entities or to entities carrying specific
labels.
match is an object with up to three optional kinds — installs, components, actions. For each kind, list the
ids you care about and/or a label selector:
{
"match": {
"installs": { "ids": ["ins_abc", "ins_def"] },
"components": { "selector": { "match_labels": { "env": "prod" } } }
}
}
How matching works:
- An event is delivered if any populated kind matches.
- Within a kind, the entity matches if its ID is in
ids or its labels satisfy selector.
- A
selector requires every match_labels entry to match (AND); use "*" as the value to require only that the
key is present.
- An empty filter (
{}) for a kind means “any entity of this kind”.
- Omit
match entirely (or send null) for the org-wide default.
Examples
Every event, but only for two specific installs:
{
"interests": { "all_events": true },
"match": { "installs": { "ids": ["ins_abc123", "ins_def456"] } }
}
Drift-only on components labelled env=prod:
{
"interests": {
"resources": {
"components": { "outcome": "none", "drift_detected": true }
}
},
"match": {
"components": { "selector": { "match_labels": { "env": "prod" } } }
}
}
The same URL can be registered multiple times in one Org with different match predicates — for example, a noisy
“all events” subscription to a staging endpoint plus a focused “drift only on env=prod components” subscription to a
paging endpoint.
Two primitives: workflows and workflow steps
The webhook surface exposes exactly two primitives:
- Workflow lifecycle (
com.nuon.workflow.lifecycle.v1) — fires for the workflow as a whole. Carries the
workflow’s type (e.g. provision, reprovision, manual_deploy, action_workflow_run) and its owner
(installs, apps, app_branches).
- Workflow step lifecycle (
com.nuon.workflow_step.lifecycle.v1) — fires for each step within a workflow.
Carries the step’s name, idx, and target_type (e.g. install_deploys, install_sandbox_runs,
install_action_workflow_runs) along with denormalized component_id / sandbox_id when applicable.
You do not need to memorize an operation taxonomy. To know “did the sandbox finish provisioning?”, look at workflow
events with data.workflow.type == "provision" and a step whose target_type == "install_sandbox_runs" succeeded.
To know “did this component deploy?”, look at a step whose target_type == "install_deploys" and inspect
data.step.component_id.
Transitions
Both primitives use the same transition vocabulary:
| Transition | When it fires |
|---|
started | The workflow / step begins execution |
succeeded | Execution completed successfully |
failed | Execution errored or validation rejected the input |
cancelled | Execution was cancelled |
data.transition is the transition name. data.outcome.status mirrors it on *.succeeded, *.failed, and
*.cancelled events. Validation failures of the workflow / step wrappers surface as a failed outcome on the
following execute event — there is no separate validate event.
A typical Install provision produces, in order:
workflow.lifecycle started for the provision workflow.
- For each step in the workflow:
workflow_step.lifecycle started → workflow_step.lifecycle succeeded (or
failed / cancelled).
workflow.lifecycle succeeded for the provision workflow once every step terminates.
The body is a CloudEvents v1.0 JSON envelope sent with Content-Type: application/cloudevents+json; charset=utf-8.
Nuon-specific extension attributes (nuonorgid, nuonkind, nuontransition) are mirrored on the envelope for
routing.
Workflow lifecycle event
{
"specversion": "1.0",
"id": "9f6c5b4e-…",
"type": "com.nuon.workflow.lifecycle.v1",
"source": "//nuon.co/ctl-api",
"time": "2026-04-28T12:34:56Z",
"subject": "org_…/workflow/inwYY…/started",
"datacontenttype": "application/json",
"nuonorgid": "org_…",
"nuonkind": "workflow",
"nuontransition": "started",
"interests": [
"resource:installs",
"op:installs.provision",
"event:lifecycle.started"
],
"data": {
"kind": "workflow",
"transition": "started",
"org_id": "org_…",
"workflow": {
"id": "inwYY…",
"type": "provision",
"owner_id": "ins_…",
"owner_type": "installs"
},
"links": {
"org": "https://app.nuon.co/org_…",
"install": "https://app.nuon.co/org_…/installs/ins_…",
"workflow": "https://app.nuon.co/org_…/installs/ins_…/workflows/inwYY…"
}
}
}
Workflow step lifecycle event
{
"specversion": "1.0",
"id": "3487f2f5-…",
"type": "com.nuon.workflow_step.lifecycle.v1",
"source": "//nuon.co/ctl-api",
"time": "2026-04-28T12:35:42Z",
"subject": "org_…/workflow_step/inwYY…/iws_…/succeeded",
"datacontenttype": "application/json",
"nuonorgid": "org_…",
"nuonkind": "workflow_step",
"nuontransition": "succeeded",
"interests": [
"resource:components",
"op:components.deploy",
"event:lifecycle.succeeded",
"outcome:completion"
],
"data": {
"kind": "workflow_step",
"transition": "succeeded",
"org_id": "org_…",
"workflow": {
"id": "inwYY…",
"type": "provision",
"owner_id": "ins_…",
"owner_type": "installs"
},
"step": {
"id": "iws_…",
"name": "deploy api (apply)",
"idx": 7,
"target_type": "install_deploys",
"target_id": "ind_…",
"component_id": "cmp_…",
"execution_type": "system"
},
"outcome": {
"status": "succeeded",
"duration_ms": 12345
},
"links": {
"org": "https://app.nuon.co/org_…",
"install": "https://app.nuon.co/org_…/installs/ins_…",
"workflow": "https://app.nuon.co/org_…/installs/ins_…/workflows/inwYY…",
"component": "https://app.nuon.co/org_…/installs/ins_…/components/cmp_…"
}
}
}
Nested workflows: parent
When a workflow is launched from another workflow’s step (for example, an action workflow run launched from a
deploy step), the child workflow’s events include a data.parent block:
"parent": {
"workflow_id": "inwParent…",
"step_id": "iwsParentStep…",
"kind": "workflow_step"
}
parent is omitted for top-level workflows.
Field reference
| Field | Description |
|---|
nuonorgid | Org id this event belongs to. Nuon CloudEvents extension; mirrors data.org_id. |
nuonkind | workflow or workflow_step. Mirrors data.kind. |
nuontransition | started, succeeded, failed, or cancelled. Mirrors data.transition. |
interests | Slug list produced by Nuon’s classifier for this event. Always contains a resource:<kind> and op:<kind>.<sub-op> slug, plus an event:<…> slug identifying the transition (e.g. event:lifecycle.succeeded, event:approval.request, event:drift.detected) and zero or more outcome:<…> slugs (outcome:completion on terminal events, plus outcome:failures on failed or cancelled transitions). Consumers can route by slug prefix without re-implementing the classifier. |
data.kind | workflow or workflow_step. |
data.transition | started, succeeded, failed, or cancelled. |
data.org_id | Org id. |
data.workflow.id | Workflow id. Stable across all events for the workflow. |
data.workflow.type | Workflow kind (provision, reprovision, manual_deploy, action_workflow_run, etc.). |
data.workflow.owner_id | Id of the entity that owns the workflow (an install, app, or app branch). |
data.workflow.owner_type | One of installs, apps, app_branches. |
data.step.id | Workflow step id (workflow_step events only). |
data.step.name | Human-readable step name (for example, "deploy api (apply)"). |
data.step.idx | Step index within the workflow. |
data.step.target_type | The kind of resource the step manipulates: install_deploys, install_sandbox_runs, etc. |
data.step.target_id | Id of the manipulated resource. |
data.step.component_id | Component id when target_type == "install_deploys". |
data.step.sandbox_id | Sandbox id when target_type == "install_sandbox_runs". |
data.step.execution_type | system, user, approval, skipped, or hidden. |
data.parent | Present when this workflow was launched from another workflow’s step. See above. |
data.outcome.status | Mirrors data.transition on terminal events. Omitted on started. |
data.outcome.error | Human-readable error message. Set only on failed events. |
data.outcome.duration_ms | How long the workflow / step took to run, in milliseconds. |
data.links | Dashboard URLs for the org, install, workflow, sandbox, and component (when applicable). |
Verifying signatures
When a webhook is created with --secret, Nuon signs the raw request body with HMAC-SHA256 using your secret and sends
the lowercase hex digest in the X-Nuon-Signature header. Reject any request whose signature does not match. When no
secret is configured, no signature header is sent.
End-to-end example
-
Start your receiver locally and expose it (for example with
ngrok http 8080).
-
Register the webhook with your Org:
nuon orgs webhooks create \
--url https://<your-public-host>/webhooks/workflow/lifecycle \
--secret $NUON_WEBHOOK_SECRET
-
Trigger a workflow. The smallest reliable trigger is reprovisioning a Sandbox:
nuon installs reprovision-sandbox --skip-components
-
Your receiver should record one
workflow.lifecycle.started, a sequence of workflow_step.lifecycle.* deliveries
(one started + one terminal per step), and finally a workflow.lifecycle.succeeded (or failed / cancelled).
A 401 response from your receiver — for example, when the secrets do not match — surfaces in Nuon as a
delivery failure and the event is not retried.
-
When you are done, remove the webhook:
nuon orgs webhooks list
nuon orgs webhooks delete --webhook-id <webhook_id>