Skip to content

Get a webhook when a run finishes

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.

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

import { generateWebhookSecret } from '@kindgi/sdk/webhooks';
console.log(generateWebhookSecret());

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

Terminal window
kindgi secrets set ACME_WEBHOOK_SECRET --env=local --scope=tenant --app
Set ACME_WEBHOOK_SECRET at tenant in local, in your app's env file.

In development, --app writes it to your app's own env file, .env.local, where your receiver reads it, and Kindgi reads it there too. Without --app it would go to Kindgi's own .kindgi/secrets.env, which your app doesn't load (Keep local values in env files). In a deployment, store it in that deployment's secrets store, under its environment's name (see kindgi secrets). Your receiver needs the same value, from its own configuration.

There's no CLI command for webhook endpoints; register one from your app (or any HTTP client) with the client:

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.review-order'] }, // optional
});
console.log(endpoint.endpointId, endpoint.url);
5c1d2267-4dc8-4bd6-81e8-940e620405ea 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 (the same VPC or compose network) sets KINDGI_WEBHOOK_PRIVATE_NETWORKS=allow. That opens RFC 1918, CGNAT and IPv6 unique-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 the reason: The deployment refuses this URL: private network addresses are not allowed.

  • events: run.finished, and improvement-pass.finished and approval.requested, both below.

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

    {"error":{"code":"webhook-secret-not-found","message":"No secret \"NOPE_SECRET\" in \"local\": store it first, then register the endpoint",…}}
  • filter narrows which runs reach the endpoint: flowIds (any version of these flows), projectId, and includeDryRuns (dry runs are left out unless it's true). Without a filter, every run you start does.

Only runs your app or the CLI started send run.finished; the runs inside them (an agent step's turn) don't.

When a run of acme.review-order ends, your URL gets a POST:

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=
{
"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 when it failed.
  • usage is what the run's model calls cost, its agent steps included: calls (failed ones too), costUsd and tokens. This flow calls no model, so it's all zeros. See Cost per run and per customer. A runtime before 0.1.3 doesn't send it.
  • The event carries the run's identity and outcome, never its input or output. Fetch the run (runs.get) for its output.
  • webhook-id is the event's id, the same on every retry.

When your app starts an agent's run (a turn in a conversation), the event is the same, with flowId agent.turn and an agent saying which agent ran:

{
"id": "62340028-b9ea-4b3c-911a-9958ecdf64de",
"type": "run.finished",
"createdAt": "2026-10-11T06:05:07.444Z",
"data": {
"run": {
"id": "73628759-cb27-4813-94f7-f872bb3a78ef",
…
"flowId": "agent.turn",
"flowVersion": "1.1.0",
"status": "completed",
…
"agent": {
"id": "acme.order-assistant",
"version": "0.1.0",
"conversationId": "9bd2328a-5c30-4cf9-ab15-4ddc74dc898d",
"via": "latest"
}
}
}
}
  • agent.id and agent.version are the agent and the version that ran, and conversationId is the conversation the turn belongs to.
  • via says why that version ran: latest (no version is live for the run's scope), live (the version live for its scope), explicit (your app named the version) or conversation (the conversation's own).
  • usage counts the agent's model calls, as on a flow's run.
  • These are the fields GET /v1/runs/<run-id> shows. In Python, parse_event gives them as event.data.run.agent.
  • A flow's run has no agent, and neither does an event from a runtime before 0.1.6.

An endpoint that also lists improvement-pass.finished in its events gets one when any improvement pass ends, completed, failed or cancelled, whoever started it. Its data.pass is the pass as GET /v1/improvement-passes/<pass-id> shows it; its outcome names the proposal it wrote, if it wrote one. Of the filter, only projectId (the pass's project) applies; flowIds and includeDryRuns are about runs. The Python client's parse_event reads it.

An endpoint that lists approval.requested gets one each time an approval starts waiting for a reviewer (an escalation opens a new approval, so a new event). Assigning it later sends no second event: read the approval for its assignedTo. Its data.approval says which approval and who may decide it: approvalId, projectId, requiredRole, title, assignedTo when it's assigned, createdAt, expiresAt, and url, its page in the console when the runtime knows its public address. It never carries what the approval is about (no context, no tool call or run input): that stays behind sign-in. Of the filter, only projectId (the approval's project) applies. An approval that's rolled back before it's saved sends nothing. One, as an endpoint gets it (ids elided; webhook-id is the approval's id), with assignedTo and expiresAt there when they're set:

{
"id": "…",
"type": "approval.requested",
"createdAt": "2026-10-10T01:37:31.198Z",
"data": {
"approval": {
"approvalId": "…",
"projectId": "…",
"requiredRole": "standard",
"title": "Refund over the limit",
"createdAt": "2026-10-10T01:37:31.198Z",
"url": "…/console/approvals/…"
}
}
}

To email the reviewers themselves instead, see reviewer email.

Answer with a 2xx status. Any other answer, or none, is retried with backoff (a minute later, then five minutes, then longer). A delivery is at least once: the same event can arrive twice, so deduplicate on webhook-id.

Outside development, every attempt checks the receiver's address again, with the runtime's settings at that moment. An attempt to an address it refuses fails with lastError: "url-refused" and is retried like any other: set KINDGI_WEBHOOK_PRIVATE_NETWORKS=allow, and the queued retries to a private receiver go out. Verify a webhook has receivers that check the signature and do that.

Tenant admins have a Webhooks page: every endpoint the tenant sends to, and how its deliveries went. Times are UTC.

The Webhooks page: "2 endpoints; 1 is failing (its last delivery isn't getting through).", Order system (a run finished, signed with dev/hooks-signing, "Of its 1 delivery, 1 was delivered.") and Old CRM ("Of its 1 delivery, 0 were delivered and 1 is still being sent.").

  • The headline counts the endpoints and the failing ones: "2 endpoints; 1 is failing (its last delivery isn't getting through)."
  • Each endpoint says what it sends ("a run finished"), the secret it signs with, by name only, and how its recent deliveries went: "Of its 1 delivery, 1 was delivered."

An endpoint's page says what it sends and how it signs: "Sends a run finished, signed with the secret dev/hooks-signing (its value stays in the secrets store)." Then each delivery, newest first, as a story:

  • "Delivered after 1 attempt";
  • "Retrying after 2 attempts (nothing is listening at that address), next attempt …", or the receiver's answer instead, such as "(HTTP 500)";
  • "Failed after 6 attempts: HTTP 500", once it has stopped trying;
  • for a finished run, the run's outcome and Open the run.

The other reasons it gives are "it didn't answer in time", "its host name doesn't resolve", "its address is one webhooks may not go to", "its TLS certificate wasn't accepted" and "the connection failed". All, Failed, Being sent and Delivered filter the list.

An endpoint's page: what it sends and its secret by name, Send a test and Unregister, "Of its 2 deliveries, 2 were delivered.", the All, Failed, Being sent and Delivered filters, and two deliveries: a test and a run finished (Completed, Open the run), each "Delivered after 1 attempt" with Send again.

  • Send a test queues a test event ("Queued: follow it below."), which shows in the list as "a test".
  • Send again queues that delivery once more ("Queued.").
  • Unregister: "Nothing more is sent to it. Deliveries still waiting are marked failed." It can't be brought back from the console: add it again. Its old address then says "No endpoint by that id here: it was unregistered, or never existed."

A runtime without a secrets store sends no webhooks, and the page says so: "Outbound webhooks aren't turned on here". Endpoints sign with a secret kept in the store, so set KINDGI_SECRETS_BACKEND and restart the runtime.

The Webhooks page on a runtime without a secrets store: "Outbound webhooks aren't turned on here", and that they need a secrets store, set with KINDGI_SECRETS_BACKEND.