Skip to main content

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:
{ "all_events": true }
{
  "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:
ResourceSupported ops
installsprovision, deprovision, reprovision
componentsdeploy, teardown
sandboxesprovision, reprovision, deprovision
install_configurationsinputs, secrets
runnersprovision, reprovision, inactive
actionsrun
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

FieldTypeDefaultDescription
ops[]string[]Sub-ops to subscribe to. An empty (or omitted) list means every sub-op for the resource.
outcomestring"all"One of all, completion, failures, or none. Filters lifecycle events on terminal status; none mutes lifecycle for this resource entirely.
approval_requestsboolfalseDeliver workflow-step approval requests for this resource.
approval_responsesboolfalseDeliver workflow-step approval responses (approved / rejected) for this resource.
drift_detectedboolfalseDeliver 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):
{ "all_events": true }
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:
  1. 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).
  2. 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:
TransitionWhen it fires
startedThe workflow / step begins execution
succeededExecution completed successfully
failedExecution errored or validation rejected the input
cancelledExecution 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:
  1. workflow.lifecycle started for the provision workflow.
  2. For each step in the workflow: workflow_step.lifecycle startedworkflow_step.lifecycle succeeded (or failed / cancelled).
  3. workflow.lifecycle succeeded for the provision workflow once every step terminates.

Payload format

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

FieldDescription
nuonorgidOrg id this event belongs to. Nuon CloudEvents extension; mirrors data.org_id.
nuonkindworkflow or workflow_step. Mirrors data.kind.
nuontransitionstarted, succeeded, failed, or cancelled. Mirrors data.transition.
interestsSlug 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.kindworkflow or workflow_step.
data.transitionstarted, succeeded, failed, or cancelled.
data.org_idOrg id.
data.workflow.idWorkflow id. Stable across all events for the workflow.
data.workflow.typeWorkflow kind (provision, reprovision, manual_deploy, action_workflow_run, etc.).
data.workflow.owner_idId of the entity that owns the workflow (an install, app, or app branch).
data.workflow.owner_typeOne of installs, apps, app_branches.
data.step.idWorkflow step id (workflow_step events only).
data.step.nameHuman-readable step name (for example, "deploy api (apply)").
data.step.idxStep index within the workflow.
data.step.target_typeThe kind of resource the step manipulates: install_deploys, install_sandbox_runs, etc.
data.step.target_idId of the manipulated resource.
data.step.component_idComponent id when target_type == "install_deploys".
data.step.sandbox_idSandbox id when target_type == "install_sandbox_runs".
data.step.execution_typesystem, user, approval, skipped, or hidden.
data.parentPresent when this workflow was launched from another workflow’s step. See above.
data.outcome.statusMirrors data.transition on terminal events. Omitted on started.
data.outcome.errorHuman-readable error message. Set only on failed events.
data.outcome.duration_msHow long the workflow / step took to run, in milliseconds.
data.linksDashboard 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

  1. Start your receiver locally and expose it (for example with ngrok http 8080).
  2. Register the webhook with your Org:
    nuon orgs webhooks create \
      --url https://<your-public-host>/webhooks/workflow/lifecycle \
      --secret $NUON_WEBHOOK_SECRET
    
  3. Trigger a workflow. The smallest reliable trigger is reprovisioning a Sandbox:
    nuon installs reprovision-sandbox --skip-components
    
  4. 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.
  5. When you are done, remove the webhook:
    nuon orgs webhooks list
    nuon orgs webhooks delete --webhook-id <webhook_id>