> ## 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 as well as operational
notifications for your Nuon [Org](/concepts/glossary). Whenever a subscribed event occurs, Nuon `POST`s a
[CloudEvents v1.0](https://cloudevents.io) envelope to every matching 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`, `unhealthy` |
| `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`.                             |
| `component_health`   | `bool`     | `false` | Deliver a notification when a component's live health crosses into degraded or unhealthy, **and when it recovers**. Only meaningful for `components`.              |
| `install_degraded`   | `bool`     | `false` | Deliver a notification when the install's composite health crosses into degraded or unhealthy, **and when it returns to healthy**. Only meaningful for `installs`. |

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

#### Component health

To receive component health notifications, set `component_health: true` on `components`. One flag covers both
directions: the notification fires when a component's live health crosses into `degraded` or `unhealthy`, and
again when it recovers, so you always get the resolution to a failure you were told about. A verdict of
`unknown` — the runner is offline or its observations are stale — never notifies; unhealthy runners have their
own event, below. A worsening inside the bad band (`degraded` to `unhealthy`) does not re-notify. Like
`drift_detected`, the flag is gated **independently of `outcome`**.

Set `install_degraded: true` on `installs` for the same signal one level up: it fires when the install's
composite health crosses into degraded or unhealthy, and when it returns to healthy. Subscribing to both levels
means a single-component outage produces two messages.

#### Unhealthy runners

Runners emit `runners.unhealthy` when the 15-minute runner health check first
transitions a runner from `active` to `offline` because its primary process is
missing. Subsequent checks do not emit another notification while the runner
remains offline. If the runner recovers and later transitions from `active` to
`offline` again, a new notification is emitted.

This operation is separate from process-level `runners.inactive` events, which
can occur during routine process shutdown and rotation. Subscribe by including
`unhealthy` 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.

## Event types

The webhook surface exposes two workflow lifecycle primitives plus dedicated notification event types:

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.
3. **Runner unhealthy** (`com.nuon.runner.unhealthy.v1`) — fires once when the runner health check transitions a runner
   from `active` to `offline`. Carries `kind: runner_unhealthy`, `transition: unhealthy`, runner and runner-group
   metadata, the reason, and install context when the runner belongs to an install.

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

<Note>
  An `owner_type` of `app_branches` means the workflow is an [app branch](/concepts/app-branches) run: a rollout across
  a fleet of installs rather than work on a single install. Its step names include `plan install group: <name>` and
  `deploy install group: <name>`, one pair per deployment group, so you can watch a staged rollout progress and drive
  the next approval through the API. See [configuring app branches](/guides/app-branches).
</Note>

## 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                            |
| `unhealthy` | A runner transitioned from `active` to `offline`   |

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

## Delivery guarantees and failures

Nuon webhooks are best-effort notifications, not a durable event stream.

* A final `2xx` response is required for a delivery to succeed.
* Nuon waits up to 5 seconds for a response. Endpoints should acknowledge promptly and perform long-running
  processing asynchronously.
* Network errors, timeouts, and non-`2xx` responses are treated as delivery failures. Nuon does not retry these
  failures or provide delivery history or replay.
* A failed webhook does not interrupt the workflow that produced the event or prevent delivery attempts to other
  webhook subscriptions.
* Rare infrastructure interruptions can result in duplicate deliveries when Nuon cannot determine whether an earlier
  request completed. A duplicate logical event may have a different CloudEvents `id`.

Make webhook handlers idempotent. Persist or enqueue the event before returning a `2xx` response, and derive an
idempotency key from the relevant workflow, step, resource, and transition fields rather than relying only on the
CloudEvents `id`.

## 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`, `event:component.unhealthy`, `event:component.recovered`, `event:install.degraded`) 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, such as when the secrets do not match, is a delivery failure. See
   [Delivery guarantees and failures](#delivery-guarantees-and-failures).

5. When you are done, remove the webhook:

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