# Get a webhook when a run finishes

> Store a signing secret, register a webhook endpoint that refers to it by name, and receive a signed run.finished request when a run ends.

This guide has Kindgi send your app a signed `run.finished` request each time a run ends, so your app needn't poll.

Kindgi signs each request with a secret that your receiver knows too. The secret lives with your other secrets, and the endpoint refers to it by name. Kindgi never stores it with the endpoint.

This guide is about Kindgi **sending** your app webhooks. To have a webhook from another system start a run, see [Start runs from WooCommerce, Drupal or any signed webhook](https://docs.kindgi.com/v0.1.9/guides/webhooks/start-runs-from-webhooks.md).

## Before you start

- **`kindgi dev` running** in your pack's directory, with Kindgi 0.1's CLI.
- **A flow to watch,** registered on that runtime. This guide filters on `acme.check-order-stock`; use your own flow's id.
- **Your app's server,** on Node 22.12 or later, Python 3.11 or later, or Java 17 or later, with Kindgi 0.1's client and `KINDGI_API_URL` and `KINDGI_API_TOKEN` set.
- **A route in your app that receives the request,** such as `http://localhost:8787/hooks/kindgi`. [Verify a webhook](https://docs.kindgi.com/v0.1.9/guides/webhooks/verify-a-webhook.md) has receivers to start from.

## 1. Store a signing secret

Kindgi and your receiver both need the same secret: Kindgi signs with it, and your receiver checks the signature.

A signing secret is `whsec_` and the base64 of at least 24 random bytes. Generate one:

- **TypeScript**

  `scripts/new-webhook-secret.ts`

  ```ts
  import { generateWebhookSecret } from '@kindgi/sdk/webhooks';

  console.log(generateWebhookSecret());
  ```

- **Python**

  `scripts/new_webhook_secret.py`

  ```python
  from kindgi import webhooks

  print(webhooks.generate_secret())
  ```

- **Java**

  `src/main/java/com/acme/app/NewWebhookSecret.java`

  ```java
  package com.acme.app;

  import com.kindgi.client.Kindgi;

  public final class NewWebhookSecret {
    public static void main(String[] args) {
      Kindgi kindgi = Kindgi.create(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev

      System.out.println(kindgi.webhookEndpoints().generateSecret().secret());
    }
  }
  ```

  The Java client asks the runtime for the secret (`POST /v1/webhook-endpoints/generate-secret`), so this needs `KINDGI_API_URL` and `KINDGI_API_TOKEN`, or the running `kindgi dev`.

- **Scala**

  `src/main/scala/acme/app/NewWebhookSecret.scala`

  ```scala
  package acme.app

  import com.kindgi.client.Kindgi

  object NewWebhookSecret {
    def main(args: Array[String]): Unit = {
      val kindgi = Kindgi.create() // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev

      println(kindgi.webhookEndpoints().generateSecret().secret)
    }
  }
  ```

  The Java client asks the runtime for the secret (`POST /v1/webhook-endpoints/generate-secret`), so this needs `KINDGI_API_URL` and `KINDGI_API_TOKEN`, or the running `kindgi dev`.

It prints a new secret on one line: `whsec_` and the base64 of 32 random bytes.

Store it as a secret, under a name. The CLI asks for the value and doesn't echo it:

- **TypeScript**

  ```sh
  pnpm exec kindgi secrets set ACME_WEBHOOK_SECRET --env=local --scope=tenant --app
  ```

- **Python**

  ```sh
  uv run kindgi secrets set ACME_WEBHOOK_SECRET --env=local --scope=tenant --app
  ```

- **Java**

  ```sh
  ./kindgiw secrets set ACME_WEBHOOK_SECRET --env=local --scope=tenant --app
  ```

- **Scala**

  ```sh
  ./kindgiw secrets set ACME_WEBHOOK_SECRET --env=local --scope=tenant --app
  ```

* `ACME_WEBHOOK_SECRET` is the secret's name. The endpoint refers to it by this name.
* `--env=local` is the environment the secret belongs to. Under `kindgi dev`, `local` is the pack's env files.
* `--scope=tenant` is where the secret lives: the tenant, rather than one org (`org:<orgId>`) or project (`project:<projectId>`).
* `--app` writes it to your app's own env file, `.env.local`, where your receiver reads it. Kindgi reads it there too. Without `--app`, it goes to Kindgi's own `.kindgi/secrets.env`, which your app doesn't load, as [Keep local values in env files](https://docs.kindgi.com/v0.1.9/guides/secrets/env-files.md) explains.

It prints the secret's name, scope, environment and version as JSON, then:

```text excerpt
…
  Set ACME_WEBHOOK_SECRET at tenant in local, in your app's env file.
```

In a deployment, store it in that deployment's secrets store, under its environment's name, with [`kindgi secrets`](https://docs.kindgi.com/v0.1.9/reference/cli/secrets.md). Your receiver needs the same value, from its own configuration.

## 2. Register the endpoint

The endpoint tells Kindgi where to send the request, and with which secret.

There's no CLI command for webhook endpoints. Register one with the client, from your app or a script. Ask your coding agent: "I want Kindgi to send my app a webhook when a run of my flow finishes." For reference, the code looks like this:

- **TypeScript**

  `scripts/register-webhook.ts`

  ```ts
  import { createClient } from '@kindgi/sdk/client';

  const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN

  const endpoint = await kindgi.webhookEndpoints.create({
    url: 'http://localhost:8787/hooks/kindgi',
    events: ['run.finished'],
    secretRef: { envName: 'local', name: 'ACME_WEBHOOK_SECRET' },
    filter: { flowIds: ['acme.check-order-stock'] }, // optional
  });
  console.log(endpoint.endpointId, endpoint.url);
  ```

  It prints the endpoint's id and URL:

  ```text
  5c1d2267-4dc8-4bd6-81e8-940e620405ea http://localhost:8787/hooks/kindgi
  ```

- **Python**

  `scripts/register_webhook.py`

  ```python
  from kindgi.client import Kindgi

  kindgi = Kindgi()  # KINDGI_API_URL, KINDGI_API_TOKEN

  endpoint = kindgi.webhook_endpoints.create(
      url="http://localhost:8788/hooks/kindgi",
      events=["run.finished"],
      secret_ref={"envName": "local", "name": "ACME_WEBHOOK_SECRET"},
      filter={"flowIds": ["acme.check-order-stock"]},  # optional
  )
  print(endpoint.endpoint_id, endpoint.url)
  ```

  It prints the endpoint's id and URL:

  ```text
  07fec45a-f938-4694-8ac3-f72f38a938a4 http://localhost:8788/hooks/kindgi
  ```

- **Java**

  `src/main/java/com/acme/app/RegisterWebhook.java`

  ```java
  package com.acme.app;

  import com.kindgi.client.Kindgi;
  import com.kindgi.client.models.CreateWebhookEndpointBody;
  import com.kindgi.client.models.WebhookEndpoint;
  import com.kindgi.client.models.WebhookEndpointFilter;
  import com.kindgi.client.models.WebhookEventType;
  import com.kindgi.client.models.WebhookSecretRef;
  import java.util.List;

  public final class RegisterWebhook {
    public static void main(String[] args) {
      Kindgi kindgi = Kindgi.create(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev

      WebhookEndpoint endpoint = kindgi.webhookEndpoints().create(CreateWebhookEndpointBody.builder()
          .url("http://localhost:8787/hooks/kindgi")
          .events(List.of(WebhookEventType.RUN_FINISHED))
          .secretRef(WebhookSecretRef.builder().envName("local").name("ACME_WEBHOOK_SECRET").build())
          .filter(WebhookEndpointFilter.builder().flowIds(List.of("acme.check-order-stock")).build()) // optional
          .build());
      System.out.println(endpoint.endpointId() + " " + endpoint.url());
    }
  }
  ```

  It prints the endpoint's id and URL:

  ```text
  b755d953-6dbf-4ec6-bd5a-3b55b0a87fc2 http://localhost:8787/hooks/kindgi
  ```

- **Scala**

  `src/main/scala/acme/app/RegisterWebhook.scala`

  ```scala
  package acme.app

  import com.kindgi.client.Kindgi
  import com.kindgi.client.models.{CreateWebhookEndpointBody, WebhookEndpointFilter, WebhookEventType, WebhookSecretRef}
  import scala.jdk.CollectionConverters._

  object RegisterWebhook {
    def main(args: Array[String]): Unit = {
      val kindgi = Kindgi.create() // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev

      val endpoint = kindgi.webhookEndpoints().create(CreateWebhookEndpointBody.builder()
        .url("http://localhost:8787/hooks/kindgi")
        .events(List(WebhookEventType.RUN_FINISHED).asJava)
        .secretRef(WebhookSecretRef.builder().envName("local").name("ACME_WEBHOOK_SECRET").build())
        .filter(WebhookEndpointFilter.builder().flowIds(List("acme.check-order-stock").asJava).build()) // optional
        .build())
      println(s"${endpoint.endpointId} ${endpoint.url}")
    }
  }
  ```

  It prints the endpoint's id and URL:

  ```text
  331103fc-6f6d-408f-904b-2a9299f9a2db http://localhost:8787/hooks/kindgi
  ```

* **`url`** is your receiver. Use https in production; plain http is for development. In `kindgi dev`, `localhost` reaches your machine, not the runtime's container. Outside development, the receiver must be on a public address. A self-hosted runtime whose receiver is on its own private network, such as the same VPC or compose network, sets `KINDGI_WEBHOOK_PRIVATE_NETWORKS=allow`. That opens RFC 1918, CGNAT, and IPv6 unique-local and site-local addresses, still over https only. Loopback and the cloud metadata addresses stay refused. A URL the deployment refuses gets `400 webhook-url-refused` with a reason. For a private address, it's `The deployment refuses this URL: private network addresses are not allowed`.

* **`events`** lists the events to send: `run.finished` here. `improvement-pass.finished` and `approval.requested` are the others, as [Webhook events and deliveries](https://docs.kindgi.com/v0.1.9/guides/webhooks/webhook-events.md) describes.

* **`secretRef`** names the secret: the environment it's in, `local` in development, and its name. Kindgi checks that it exists and is strong enough, and keeps only the name. A secret that isn't stored is refused:

  ```json excerpt
  {"error":{"code":"webhook-secret-not-found","message":"No secret \"NOPE_SECRET\" in \"local\": store it first, then register the endpoint",…}}
  ```

* **`filter`** (optional) narrows which runs reach the endpoint. `flowIds` names flows, any version of them. `projectId` names a project. `includeDryRuns: true` sends dry runs too, which are left out otherwise. Without a filter, every run you start reaches the endpoint.

Only runs your app or the CLI started send `run.finished`. The runs inside them, such as an agent step's turn, don't.

## 3. What your endpoint receives

When a run of `acme.check-order-stock` ends, your URL gets a `POST`:

```text
POST /hooks/kindgi
content-type: application/json
user-agent: Kindgi-Webhooks/1
webhook-id: d7bc2805-587c-4830-a36f-fddeb0b7a423
webhook-timestamp: 1791058584
webhook-signature: v1,bj8Mj6s7ln2Gd1sDRQi8+Wax2SOFHovsdMIwvNhDrgw=
```

```json
{
  "id": "d7bc2805-587c-4830-a36f-fddeb0b7a423",
  "type": "run.finished",
  "createdAt": "2026-10-03T20:16:24.554Z",
  "data": {
    "run": {
      "id": "c24869c1-9243-4531-920b-d657e05eac0b",
      "projectId": "bc654965-c4fb-4667-85f1-33e46f4a4f0c",
      "flowId": "acme.check-order-stock",
      "flowVersion": "0.1.0",
      "status": "completed",
      "dryRun": false,
      "failureMessage": null,
      "createdAt": "2026-10-03T20:16:24.465Z",
      "completedAt": "2026-10-03T20:16:24.554Z",
      "usage": {
        "calls": 0,
        "costUsd": 0,
        "tokens": { "prompt": 0, "completion": 0, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0 }
      }
    }
  }
}
```

- `status` is `completed`, `failed` or `cancelled`. `failureMessage` says why a run failed.
- `usage` is what the run's model calls cost. This flow calls no model, so it's all zeros.
- The event carries the run's identity and outcome, never its input or output. Fetch the run with `runs.get` for its output.
- `webhook-id` is the event's id, the same on every retry.

[Webhook events and deliveries](https://docs.kindgi.com/v0.1.9/guides/webhooks/webhook-events.md#runfinished) has every field, and the event for an agent's run.

## 4. Answer the request

Kindgi retries any request your receiver doesn't answer with success, so the receiver answers each one.

Check the signature, then answer with a 2xx status. Deduplicate on `webhook-id`, since the same event can arrive twice. [Verify a webhook](https://docs.kindgi.com/v0.1.9/guides/webhooks/verify-a-webhook.md) has receivers that do both.

Your app now handles each event once, from a request whose signature it has checked.

## Next

- [Verify a webhook](https://docs.kindgi.com/v0.1.9/guides/webhooks/verify-a-webhook.md)
- [Test and replay deliveries](https://docs.kindgi.com/v0.1.9/guides/webhooks/test-and-replay.md)
- [Webhook events and deliveries](https://docs.kindgi.com/v0.1.9/guides/webhooks/webhook-events.md)
- [Show runs in your app](https://docs.kindgi.com/v0.1.9/guides/runs/show-runs-in-your-app.md)
