> ## 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.

# Webhooks

> Receive workflow lifecycle events from your Nuon Org as CloudEvents over HTTP.

Org-scoped webhooks let you subscribe to **workflow** and **workflow step** lifecycle events for your Nuon
[Org](/concepts/glossary). Whenever a workflow runs (for example, an [Install](/concepts/glossary) provision, a
[Sandbox](/concepts/sandboxes) reprovision, or a component deploy), Nuon `POST`s a
[CloudEvents v1.0](https://cloudevents.io) envelope to every webhook URL registered on the Org.

Webhooks are scoped to the current Org. Manage them with the [Nuon CLI](/cli) or the dashboard.

## Manage webhooks

```sh theme={null}
# 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](#scoping-deliveries-with-match) (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](#filtering-events-with-interests) / [match predicate](#scoping-deliveries-with-match) 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:

```json theme={null}
{ "all_events": true }
```

```json theme={null}
{
  "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` |
| `stacks`                 | `version_active`                          |
| `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):

```json theme={null}
{ "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:

```json theme={null}
{
  "resources": {
    "installs":               { "outcome": "completion", "approval_requests": true, "approval_responses": true },
    "stacks":                 { "outcome": "completion" },
    "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:

```json theme={null}
{
  "resources": {
    "components": { "ops": ["deploy"], "outcome": "failures" }
  }
}
```

Drift only — components, no lifecycle, no approvals:

```json theme={null}
{
  "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](#filtering-events-with-interests) and the optional [match predicate](#scoping-deliveries-with-match)
— 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.

```sh theme={null}
# 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](#filtering-events-with-interests) 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](#scoping-deliveries-with-match), and saving from the dashboard resets the webhook to org-wide.
> To create or preserve a scoped webhook, manage it from the [CLI](#setting-interests-via-the-cli).

## Scoping deliveries with match

By default a webhook is **org-wide** — it fires for every event the
[interests filter](#filtering-events-with-interests) 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`:

```json theme={null}
{
  "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:

```json theme={null}
{
  "interests": { "all_events": true },
  "match":     { "installs": { "ids": ["ins_abc123", "ins_def456"] } }
}
```

Drift-only on components labelled `env=prod`:

```json theme={null}
{
  "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:

| 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:

1. `workflow.lifecycle started` for the provision workflow.
2. For each step in the workflow: `workflow_step.lifecycle started` → `workflow_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

```json theme={null}
{
  "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

```json theme={null}
{
  "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:

```json theme={null}
"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

1. Start your receiver locally and expose it (for example with `ngrok http 8080`).

2. Register the webhook with your Org:

   ```sh theme={null}
   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:

   ```sh theme={null}
   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:

   ```sh theme={null}
   nuon orgs webhooks list
   nuon orgs webhooks delete --webhook-id <webhook_id>
   ```
